Commit c0783535d1b for woocommerce

commit c0783535d1b1d1304fbcc41f6b9d680b87c81061
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue Apr 21 17:48:46 2026 +0200

    [DataViews - All Products] Add organization field modules (#64271)

    * 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

    * 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 event listener

    ---------

    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-organization b/packages/js/experimental-products-app/changelog/add-products-fields-organization
new file mode 100644
index 00000000000..8564badd626
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-fields-organization
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add organization and merchandising field modules for the experimental products app
diff --git a/packages/js/experimental-products-app/src/fields/button_text/field.tsx b/packages/js/experimental-products-app/src/fields/button_text/field.tsx
new file mode 100644
index 00000000000..10874890bbc
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/button_text/field.tsx
@@ -0,0 +1,27 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Button Label', 'woocommerce' ),
+	description: __(
+		'Enter the text customers will see on the button that takes them to the store.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+};
diff --git a/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx b/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx
new file mode 100644
index 00000000000..6ce954e9073
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	label: __( 'Visibility', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+	elements: [
+		{ label: __( 'Public', 'woocommerce' ), value: 'visible' },
+		{ label: __( 'Catalog', 'woocommerce' ), value: 'catalog' },
+		{ label: __( 'Search', 'woocommerce' ), value: 'search' },
+		{ label: __( 'Hidden', 'woocommerce' ), value: 'hidden' },
+	],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+};
diff --git a/packages/js/experimental-products-app/src/fields/categories/field.tsx b/packages/js/experimental-products-app/src/fields/categories/field.tsx
new file mode 100644
index 00000000000..1608b68a351
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/categories/field.tsx
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import { resolveSelect } from '@wordpress/data';
+
+import { store as coreStore } from '@wordpress/core-data';
+
+import { decodeEntities } from '@wordpress/html-entities';
+
+import { __ } from '@wordpress/i18n';
+
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { TaxonomyEdit } from '../components/taxonomy-edit';
+
+type CategoryImage = {
+	id: number;
+	src?: string;
+	alt?: string;
+};
+
+type ProductCategory = {
+	id: number;
+	name: string;
+	image?: CategoryImage;
+};
+
+const fieldDefinition = {
+	type: 'array',
+	label: __( 'Categories', 'woocommerce' ),
+	description: __(
+		'Add one or more categories to help customers find this product on your store.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	filterBy: {
+		operators: [ 'isAny', 'isNone' ],
+	},
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => {
+		return item.categories.map( ( { id } ) => id.toString() );
+	},
+	setValue: ( { value }: { value: string[] } ) => {
+		return {
+			categories: value.map( ( v ) => ( {
+				id: parseInt( v, 10 ),
+			} ) ),
+		};
+	},
+	render: ( { item } ) => {
+		return ( item.categories ?? [] )
+			.map( ( { name } ) => decodeEntities( name ?? '' ) )
+			.join( ', ' );
+	},
+	getElements: async () => {
+		const records: ProductCategory[] | null = await resolveSelect(
+			coreStore
+		).getEntityRecords( 'taxonomy', 'product_cat', { per_page: -1 } );
+		return ( records ?? [] ).map( ( { id, name, image } ) => ( {
+			value: id.toString(),
+			label: decodeEntities( name ),
+			image: image?.src ? { src: image.src, alt: image.alt } : undefined,
+		} ) );
+	},
+	Edit: ( props: DataFormControlProps< ProductEntityRecord > ) => (
+		<TaxonomyEdit
+			{ ...props }
+			taxonomy="product_cat"
+			fieldProperty="categories"
+			searchPlaceholder={ __(
+				'Search or create categories',
+				'woocommerce'
+			) }
+		/>
+	),
+};
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
new file mode 100644
index 00000000000..0cc4156f9fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
@@ -0,0 +1,463 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+import { FormTokenField, Spinner } from '@wordpress/components';
+import { store as coreStore, type Term } from '@wordpress/core-data';
+import { dispatch, useDispatch } from '@wordpress/data';
+import type { DataFormControlProps } from '@wordpress/dataviews';
+import { useCallback, useMemo, useState } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { __, sprintf } from '@wordpress/i18n';
+import { Stack } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import { useElements } from './use-elements';
+import { useAdaptiveTaxonomy } from './use-adaptive-taxonomy';
+import type { Item, ItemImage, TaxonomyTermRef } from './types';
+
+export type { Item, ItemImage, TaxonomyTermRef };
+
+const CREATABLE_VALUE = '__create__';
+
+type TaxonomyEditProps< T > = {
+	taxonomy: string;
+	fieldProperty: keyof T;
+	searchPlaceholder?: string;
+	/**
+	 * When set, fetches up to this many terms initially. If the store has
+	 * more, switches to server-side search. If omitted, uses the field's
+	 * elements/getElements for client-side filtering.
+	 */
+	serverSearchThreshold?: number;
+	/**
+	 * Known total term count for this taxonomy (e.g. from hydrated boot data).
+	 * Used with serverSearchThreshold to decide search mode without extra requests.
+	 */
+	termCount?: number;
+};
+
+type TokenValue = string | { value: string };
+
+function getErrorMessage( error: unknown ): string {
+	if ( error instanceof Error ) {
+		return error.message;
+	}
+	if ( typeof error === 'object' && error !== null && 'message' in error ) {
+		const errorWithMessage = error as Record< string, unknown >;
+		if ( typeof errorWithMessage.message === 'string' ) {
+			return errorWithMessage.message;
+		}
+	}
+	return String( error );
+}
+
+function itemsToTermRefs( selectedItems: Item[] ): TaxonomyTermRef[] {
+	return selectedItems
+		.filter( ( item ) => item.value !== CREATABLE_VALUE )
+		.map( ( item ) => ( { id: parseInt( item.value, 10 ) } ) )
+		.filter( ( ref ) => ! Number.isNaN( ref.id ) );
+}
+
+function isTaxonomyTermRef( value: unknown ): value is TaxonomyTermRef {
+	if ( typeof value !== 'object' || value === null ) {
+		return false;
+	}
+	if ( ! ( 'id' in value ) ) {
+		return false;
+	}
+	const { id } = value as { id: unknown };
+	return typeof id === 'number';
+}
+
+function isTermRecord( value: unknown ): value is Term {
+	if ( typeof value !== 'object' || value === null ) {
+		return false;
+	}
+	if ( ! ( 'id' in value ) || ! ( 'name' in value ) ) {
+		return false;
+	}
+	const term = value as Record< string, unknown >;
+	return typeof term.id === 'number' && typeof term.name === 'string';
+}
+
+function createFieldChange< T extends Record< string, unknown > >(
+	fieldProperty: keyof T,
+	value: TaxonomyTermRef[]
+): Partial< T > {
+	return { [ fieldProperty ]: value } as Partial< T >;
+}
+
+function getTokenValue( token: TokenValue ): string {
+	return typeof token === 'string' ? token : token.value;
+}
+
+function normalizeItemLabel( label: string ): string {
+	return label.trim().toLocaleLowerCase();
+}
+
+function getHelpContent(
+	description: ReactNode,
+	isLoading: boolean,
+	isServerSearch: boolean,
+	inputValue: string
+) {
+	const shouldPromptToSearch = isServerSearch && ! inputValue.trim();
+
+	if ( ! description && ! isLoading && ! shouldPromptToSearch ) {
+		return null;
+	}
+
+	return (
+		<div className="components-base-control__help">
+			<Stack direction="column" style={ { gap: '4px' } }>
+				{ description && <span>{ description }</span> }
+				{ isLoading && (
+					<Stack
+						direction="row"
+						align="center"
+						style={ { gap: '4px' } }
+					>
+						<Spinner />
+						<span>{ __( 'Loading results…', 'woocommerce' ) }</span>
+					</Stack>
+				) }
+				{ shouldPromptToSearch && (
+					<span>{ __( 'Type to search…', 'woocommerce' ) }</span>
+				) }
+			</Stack>
+		</div>
+	);
+}
+
+export function TaxonomyEdit< T extends Record< string, unknown > >( {
+	data,
+	field,
+	onChange,
+	taxonomy,
+	fieldProperty,
+	searchPlaceholder,
+	serverSearchThreshold,
+	termCount,
+}: DataFormControlProps< T > & TaxonomyEditProps< T > ) {
+	const isAdaptiveMode = serverSearchThreshold !== undefined;
+
+	const [ inputValue, setInputValue ] = useState( '' );
+	const [ isCreating, setIsCreating ] = useState( false );
+	// Tracks items the user has selected or created, so chips persist
+	// even when search results change. Initialized from entity data.
+	const [ knownItems, setKnownItems ] = useState< Item[] >( () => {
+		if ( ! isAdaptiveMode ) {
+			return [];
+		}
+		const rawRefs = data?.[ fieldProperty ];
+		if ( ! Array.isArray( rawRefs ) ) {
+			return [];
+		}
+		return rawRefs
+			.filter( isTaxonomyTermRef )
+			.map( ( ref ) => {
+				const name = ( ref as { id: number; name?: string } ).name;
+				return name
+					? {
+							value: ref.id.toString(),
+							label: decodeEntities( name ),
+					  }
+					: null;
+			} )
+			.filter( ( item ): item is Item => item !== null );
+	} );
+	const { createErrorNotice } = useDispatch(
+		// eslint-disable-next-line @wordpress/data-no-store-string-literals -- @wordpress/notices types are unavailable in this package.
+		'core/notices'
+	) as {
+		createErrorNotice: (
+			message: string,
+			options?: {
+				type?: string;
+			}
+		) => void | Promise< unknown >;
+	};
+
+	// Legacy mode: load all elements from field definition.
+	const { elements: fieldItems, isLoading: isFieldLoading } = useElements( {
+		elements: isAdaptiveMode
+			? undefined
+			: ( field.elements as Item[] | undefined ),
+		getElements: isAdaptiveMode
+			? undefined
+			: ( field.getElements as ( () => Promise< Item[] > ) | undefined ),
+	} );
+
+	// Adaptive mode: probes term count, uses client-side or server search.
+	const {
+		items: adaptiveItems,
+		isLoading: isAdaptiveLoading,
+		isServerSearch,
+	} = useAdaptiveTaxonomy( {
+		taxonomy,
+		inputValue: isAdaptiveMode ? inputValue : '',
+		knownItems: isAdaptiveMode ? knownItems : [],
+		threshold: serverSearchThreshold,
+		termCount,
+	} );
+
+	const isLoading = isAdaptiveMode ? isAdaptiveLoading : isFieldLoading;
+	const baseItems = isAdaptiveMode ? adaptiveItems : fieldItems;
+
+	const items: Item[] = useMemo( () => {
+		const existingValues = new Set( baseItems.map( ( i ) => i.value ) );
+		const newItems = knownItems.filter(
+			( i ) => ! existingValues.has( i.value )
+		);
+		return [ ...baseItems, ...newItems ];
+	}, [ baseItems, knownItems ] );
+
+	const itemsMap = useMemo( () => {
+		return new Map( items.map( ( item ) => [ item.value, item ] ) );
+	}, [ items ] );
+
+	const itemsByLabel = useMemo( () => {
+		return new Map(
+			items.map( ( item ) => [ normalizeItemLabel( item.label ), item ] )
+		);
+	}, [ items ] );
+
+	const selectedItems: Item[] = useMemo( () => {
+		const rawRefs = data?.[ fieldProperty ];
+		const termRefs: TaxonomyTermRef[] = Array.isArray( rawRefs )
+			? rawRefs.filter( isTaxonomyTermRef )
+			: [];
+		return termRefs
+			.map( ( ref ) => itemsMap.get( ref.id.toString() ) )
+			.filter( ( item ): item is Item => item !== undefined );
+	}, [ data, fieldProperty, itemsMap ] );
+
+	const value = useMemo(
+		() => selectedItems.map( ( item ) => item.value ),
+		[ selectedItems ]
+	);
+
+	const suggestions = useMemo(
+		() => items.map( ( item ) => item.value ),
+		[ items ]
+	);
+
+	const hasImages = useMemo(
+		() => items.some( ( item ) => item.image?.src ),
+		[ items ]
+	);
+
+	const displayTransform = useCallback(
+		( token: string ) => itemsMap.get( token )?.label ?? token,
+		[ itemsMap ]
+	);
+
+	const saveTransform = useCallback(
+		( token: string ) => {
+			const trimmedToken = token.trim();
+			return itemsMap.get( trimmedToken )?.label ?? trimmedToken;
+		},
+		[ itemsMap ]
+	);
+
+	const handleValueChange = useCallback(
+		async ( tokens: TokenValue[] ) => {
+			const tokenValues = tokens.map( getTokenValue );
+
+			const createdToken = tokenValues.find(
+				( token ) =>
+					! itemsMap.has( token ) &&
+					! itemsByLabel.has( normalizeItemLabel( token ) )
+			);
+
+			const resolveSelectedItems = () =>
+				tokenValues
+					.map(
+						( token ) =>
+							itemsMap.get( token ) ??
+							itemsByLabel.get( normalizeItemLabel( token ) )
+					)
+					.filter( ( item ): item is Item => item !== undefined );
+
+			if ( createdToken ) {
+				const termName = createdToken.trim();
+				if ( ! termName ) {
+					return;
+				}
+
+				const resolvedItems = resolveSelectedItems();
+
+				setIsCreating( true );
+
+				try {
+					const result: unknown = await dispatch(
+						coreStore
+					).saveEntityRecord(
+						'taxonomy',
+						taxonomy,
+						{ name: termName },
+						{ throwOnError: true }
+					);
+
+					if ( ! isTermRecord( result ) ) {
+						// eslint-disable-next-line no-console
+						console.error(
+							'[TaxonomyEdit] Unexpected response from saveEntityRecord:',
+							result
+						);
+						return;
+					}
+
+					const newTerm = result;
+
+					if ( newTerm.id ) {
+						const newItem: Item = {
+							value: newTerm.id.toString(),
+							label: decodeEntities( newTerm.name ),
+						};
+
+						setKnownItems( ( prev ) => [ ...prev, newItem ] );
+
+						const updatedItems = [ ...resolvedItems, newItem ];
+
+						onChange(
+							createFieldChange< T >(
+								fieldProperty,
+								itemsToTermRefs( updatedItems )
+							)
+						);
+
+						setInputValue( '' );
+
+						if ( ! isServerSearch ) {
+							void (
+								dispatch( coreStore ) as unknown as {
+									invalidateResolution: (
+										selectorName: string,
+										args: unknown[]
+									) => void | Promise< unknown >;
+								}
+							 ).invalidateResolution( 'getEntityRecords', [
+								'taxonomy',
+								taxonomy,
+								{ per_page: -1 },
+							] );
+						}
+					}
+				} catch ( error ) {
+					void createErrorNotice(
+						sprintf(
+							/* translators: %s: error message */
+							__( 'Failed to create term: %s', 'woocommerce' ),
+							getErrorMessage( error )
+						),
+						{
+							type: 'snackbar',
+						}
+					);
+				} finally {
+					setIsCreating( false );
+				}
+			} else {
+				const resolvedItems = resolveSelectedItems();
+
+				// Track selected items so chips persist across search changes.
+				if ( isServerSearch ) {
+					setKnownItems( ( prev ) => {
+						const known = new Set( prev.map( ( i ) => i.value ) );
+						const added = resolvedItems.filter(
+							( item ) => ! known.has( item.value )
+						);
+						return added.length > 0 ? [ ...prev, ...added ] : prev;
+					} );
+				}
+
+				onChange(
+					createFieldChange< T >(
+						fieldProperty,
+						itemsToTermRefs( resolvedItems )
+					)
+				);
+			}
+		},
+		[
+			itemsByLabel,
+			itemsMap,
+			taxonomy,
+			fieldProperty,
+			onChange,
+			createErrorNotice,
+			isServerSearch,
+		]
+	);
+
+	const renderSuggestionItem = useCallback(
+		( { item: token }: { item: string } ) => {
+			const suggestionItem = itemsMap.get( token );
+
+			if ( ! suggestionItem ) {
+				return token;
+			}
+
+			if ( ! hasImages ) {
+				return suggestionItem.label;
+			}
+
+			return (
+				<Stack
+					direction="row"
+					align="center"
+					style={ { gap: '12px' } }
+					className="woocommerce-next-taxonomy-edit__option"
+				>
+					{ suggestionItem.image?.src ? (
+						<img
+							src={ suggestionItem.image.src }
+							alt={ suggestionItem.image.alt ?? '' }
+							className="woocommerce-next-taxonomy-edit__option-thumbnail"
+						/>
+					) : (
+						<span className="woocommerce-next-taxonomy-edit__option-thumbnail woocommerce-next-taxonomy-edit__option-thumbnail--empty" />
+					) }
+					<span className="woocommerce-next-taxonomy-edit__option-label">
+						{ suggestionItem.label }
+					</span>
+				</Stack>
+			);
+		},
+		[ hasImages, itemsMap ]
+	);
+
+	return (
+		<div className="woocommerce-next-taxonomy-edit">
+			<FormTokenField
+				__next40pxDefaultSize
+				__experimentalAutoSelectFirstMatch
+				__experimentalExpandOnFocus
+				__experimentalRenderItem={ renderSuggestionItem }
+				__experimentalShowHowTo={ false }
+				label={ field.label }
+				disabled={ isCreating }
+				displayTransform={ displayTransform }
+				maxSuggestions={ suggestions.length }
+				onChange={ handleValueChange }
+				onInputChange={ setInputValue }
+				placeholder={
+					searchPlaceholder ?? __( 'Search', 'woocommerce' )
+				}
+				saveTransform={ saveTransform }
+				suggestions={ suggestions }
+				value={ value }
+			/>
+			{ getHelpContent(
+				field.description,
+				isLoading,
+				isServerSearch,
+				inputValue
+			) }
+		</div>
+	);
+}
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
new file mode 100644
index 00000000000..b379cb07866
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
@@ -0,0 +1,26 @@
+.woocommerce-next-taxonomy-edit {
+	&__chip-thumbnail {
+		width: 19px;
+		height: 19px;
+		border-radius: 50%;
+		object-fit: cover;
+		margin-left: 1px;
+
+		&--empty {
+			background-color: var( --wp-components-color-gray-100 );
+		}
+	}
+
+	&__option {
+		width: 100%;
+	}
+
+	&__option-thumbnail {
+		width: 32px;
+		height: 32px;
+		flex-shrink: 0;
+		border-radius: 4px;
+		object-fit: cover;
+		background-color: var( --wp-components-color-gray-100 );
+	}
+}
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/types.ts b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/types.ts
new file mode 100644
index 00000000000..4f7e35cae1a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/types.ts
@@ -0,0 +1,15 @@
+export type ItemImage = {
+	src: string;
+	alt?: string;
+};
+
+export type Item = {
+	label: string;
+	value: string;
+	disabled?: boolean;
+	image?: ItemImage;
+};
+
+export type TaxonomyTermRef = {
+	id: number;
+};
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/use-adaptive-taxonomy.ts b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/use-adaptive-taxonomy.ts
new file mode 100644
index 00000000000..94a5b7e8f9d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/use-adaptive-taxonomy.ts
@@ -0,0 +1,168 @@
+/**
+ * External dependencies
+ */
+import { store as coreStore } from '@wordpress/core-data';
+import { resolveSelect } from '@wordpress/data';
+import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+
+/**
+ * Internal dependencies
+ */
+import type { Item } from './types';
+
+const DEBOUNCE_MS = 300;
+const SEARCH_PER_PAGE = 20;
+
+function isTermArray(
+	value: unknown
+): value is Array< { id: number; name: string } > {
+	return (
+		Array.isArray( value ) &&
+		value.every( ( item: unknown ) => {
+			if ( typeof item !== 'object' || item === null ) {
+				return false;
+			}
+			return (
+				'id' in item &&
+				typeof ( item as { id: unknown } ).id === 'number' &&
+				'name' in item &&
+				typeof ( item as { name: unknown } ).name === 'string'
+			);
+		} )
+	);
+}
+
+function termsToItems( terms: unknown ): Item[] {
+	if ( ! isTermArray( terms ) ) {
+		return [];
+	}
+	return terms.map( ( { id, name } ) => ( {
+		value: id.toString(),
+		label: decodeEntities( name ),
+	} ) );
+}
+
+/**
+ * Hook that adaptively fetches taxonomy terms. Uses a provided term count
+ * to decide between client-side filtering (all terms loaded) and
+ * server-side search (debounced REST API queries).
+ *
+ * @param root0            Hook parameters.
+ * @param root0.taxonomy   The taxonomy slug.
+ * @param root0.inputValue Current search input value.
+ * @param root0.knownItems Items to always include in results (selected/created).
+ * @param root0.threshold  Max terms for client-side mode. Undefined = always client-side.
+ * @param root0.termCount  Known total term count (e.g. from hydrated boot data).
+ */
+export function useAdaptiveTaxonomy( {
+	taxonomy,
+	inputValue,
+	knownItems,
+	threshold,
+	termCount,
+}: {
+	taxonomy: string;
+	inputValue: string;
+	knownItems: Item[];
+	threshold?: number;
+	termCount?: number;
+} ) {
+	const isServerSearch =
+		threshold !== undefined &&
+		termCount !== undefined &&
+		termCount > threshold;
+
+	const [ allTerms, setAllTerms ] = useState< Item[] >( [] );
+	const [ searchResults, setSearchResults ] = useState< Item[] >( [] );
+	const [ isLoading, setIsLoading ] = useState( ! isServerSearch );
+	const [ isSearching, setIsSearching ] = useState( false );
+	const requestIdRef = useRef( 0 );
+
+	// Client-side mode: fetch all terms once.
+	useEffect( () => {
+		if ( isServerSearch ) {
+			return;
+		}
+
+		let cancelled = false;
+		setIsLoading( true );
+
+		resolveSelect( coreStore )
+			.getEntityRecords( 'taxonomy', taxonomy, { per_page: -1 } )
+			.then( ( records: unknown ) => {
+				if ( ! cancelled ) {
+					setAllTerms( termsToItems( records ) );
+				}
+			} )
+			.catch( () => {
+				// Silently fail.
+			} )
+			.finally( () => {
+				if ( ! cancelled ) {
+					setIsLoading( false );
+				}
+			} );
+
+		return () => {
+			cancelled = true;
+		};
+	}, [ taxonomy, isServerSearch ] );
+
+	// Server-side mode: debounced search on input change.
+	useEffect( () => {
+		if ( ! isServerSearch ) {
+			return;
+		}
+
+		const query = inputValue.trim();
+		if ( ! query ) {
+			setSearchResults( [] );
+			setIsSearching( false );
+			return;
+		}
+
+		setIsSearching( true );
+		requestIdRef.current += 1;
+		const currentRequestId = requestIdRef.current;
+
+		const timer = setTimeout( () => {
+			resolveSelect( coreStore )
+				.getEntityRecords( 'taxonomy', taxonomy, {
+					search: query,
+					per_page: SEARCH_PER_PAGE,
+				} )
+				.then( ( records: unknown ) => {
+					if ( currentRequestId === requestIdRef.current ) {
+						setSearchResults( termsToItems( records ) );
+						setIsSearching( false );
+					}
+				} )
+				.catch( () => {
+					if ( currentRequestId === requestIdRef.current ) {
+						setIsSearching( false );
+					}
+				} );
+		}, DEBOUNCE_MS );
+
+		return () => {
+			clearTimeout( timer );
+		};
+	}, [ inputValue, taxonomy, isServerSearch ] );
+
+	// Merge known items with current results so chips persist.
+	const items = useMemo( () => {
+		const baseItems = isServerSearch ? searchResults : allTerms;
+		const resultValues = new Set( baseItems.map( ( item ) => item.value ) );
+		const missingKnown = knownItems.filter(
+			( item ) => ! resultValues.has( item.value )
+		);
+		return [ ...missingKnown, ...baseItems ];
+	}, [ isServerSearch, searchResults, allTerms, knownItems ] );
+
+	return {
+		items,
+		isLoading: isLoading || isSearching,
+		isServerSearch,
+	};
+}
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/use-elements.ts b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/use-elements.ts
new file mode 100644
index 00000000000..859694f7790
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/use-elements.ts
@@ -0,0 +1,100 @@
+/**
+ * External dependencies
+ */
+import { useState, useEffect, useMemo, useRef } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import type { Item } from './types';
+
+const EMPTY_ARRAY: Item[] = [];
+
+export function useElements( {
+	elements,
+	getElements,
+}: {
+	elements?: Item[];
+	getElements?: () => Promise< Item[] >;
+} ) {
+	const staticElements = useMemo(
+		() =>
+			Array.isArray( elements ) && elements.length > 0
+				? elements
+				: EMPTY_ARRAY,
+		[ elements ]
+	);
+	const [ records, setRecords ] = useState< Item[] >( staticElements );
+	const [ isLoading, setIsLoading ] = useState( false );
+	const [ refreshTrigger, setRefreshTrigger ] = useState( 0 );
+	const getElementsRef = useRef( getElements );
+
+	// Update ref when getElements changes
+	useEffect( () => {
+		getElementsRef.current = getElements;
+	}, [ getElements ] );
+
+	// Expose refresh function on window for abilities to call
+	useEffect( () => {
+		const handleRefresh = () => {
+			setRefreshTrigger( ( prev ) => prev + 1 );
+		};
+
+		window.addEventListener(
+			'woocommerce-refresh-product-taxonomies',
+			handleRefresh
+		);
+
+		return () => {
+			window.removeEventListener(
+				'woocommerce-refresh-product-taxonomies',
+				handleRefresh
+			);
+		};
+	}, [] );
+
+	useEffect( () => {
+		if ( ! getElementsRef.current ) {
+			setRecords( staticElements );
+			return;
+		}
+
+		let cancelled = false;
+		setIsLoading( true );
+		getElementsRef
+			.current()
+			.then( ( fetchedElements ) => {
+				if ( ! cancelled ) {
+					const dynamicElements =
+						Array.isArray( fetchedElements ) &&
+						fetchedElements.length > 0
+							? fetchedElements
+							: staticElements;
+					setRecords( dynamicElements );
+				}
+			} )
+			.catch( ( error: unknown ) => {
+				// eslint-disable-next-line no-console
+				console.error(
+					'[useElements] Failed to fetch elements:',
+					error
+				);
+				if ( ! cancelled ) {
+					setRecords( staticElements );
+				}
+			} )
+			.finally( () => {
+				if ( ! cancelled ) {
+					setIsLoading( false );
+				}
+			} );
+
+		return () => {
+			cancelled = true;
+		};
+	}, [ staticElements, refreshTrigger ] );
+
+	return {
+		elements: records,
+		isLoading,
+	};
+}
diff --git a/packages/js/experimental-products-app/src/fields/description/field.tsx b/packages/js/experimental-products-app/src/fields/description/field.tsx
new file mode 100644
index 00000000000..a803c802e69
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/description/field.tsx
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextareaControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+
+import type { ProductEntityRecord } from '../types';
+import { convertHtmlToPlainText } from '../../utilites';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Description', 'woocommerce' ),
+	description: __(
+		'Share the full story—include product details, features, and benefits to help customers decide.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => convertHtmlToPlainText( item.description ),
+	Edit: ( { data, onChange, field } ) => {
+		return (
+			<TextareaControl
+				label={ field.label }
+				rows={ 10 }
+				value={ convertHtmlToPlainText( data.description || '' ) }
+				onChange={ ( value ) => onChange( { description: value } ) }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/downloadable/field.tsx b/packages/js/experimental-products-app/src/fields/downloadable/field.tsx
new file mode 100644
index 00000000000..ac38d9d4517
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/downloadable/field.tsx
@@ -0,0 +1,127 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import { FormFileUpload } from '@wordpress/components';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { ListItem } from '../components/list-item';
+
+import {
+	GenericThumbnail,
+	getLastPathFromUrl,
+	isImageFromLink,
+	isItemToUpload,
+	isUploadedItem,
+	type ItemToUpload,
+	type UploadedItem,
+} from './utils';
+
+const uploadingLabel = __( 'uploading…', 'woocommerce' );
+
+const fieldDefinition = {
+	type: 'boolean',
+	label: __( 'Downloadable', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	type: 'boolean',
+	getValue: ( { item } ) => item.downloadable,
+	Edit: ( { data, onChange } ) => {
+		const downloads = ( data.downloads ?? [] ) as Array<
+			UploadedItem | ItemToUpload
+		>;
+
+		const filesToUpload = downloads.filter( isItemToUpload );
+		const uploadedFiles = downloads.filter( isUploadedItem );
+
+		const handleRemoveDownload = ( fileId: string | number ) => {
+			onChange( {
+				downloads: downloads.filter(
+					( download ) => download.file !== fileId
+				),
+			} );
+		};
+
+		const handleAddDownload = ( file: File ) => {
+			const objectUrl = URL.createObjectURL( file );
+			onChange( {
+				downloads: [
+					...downloads,
+					{
+						file: objectUrl,
+						name: file.name,
+						type: file.type,
+					},
+				],
+			} );
+		};
+
+		const items = [
+			...uploadedFiles.map( ( file ) => {
+				const thumbnail = isImageFromLink( file.file ) ? (
+					file.file
+				) : (
+					<GenericThumbnail />
+				);
+				return {
+					id: file.file,
+					title: file.name,
+					thumbnail,
+					meta: getLastPathFromUrl( file.file ),
+					altText: file.name,
+				};
+			} ),
+			...filesToUpload.map( ( file ) => {
+				const thumbnail = file.type?.startsWith( 'image/' ) ? (
+					file.file
+				) : (
+					<GenericThumbnail />
+				);
+				return {
+					id: file.file,
+					title: `${ file.name } - ${ uploadingLabel }`,
+					thumbnail,
+					meta: getLastPathFromUrl( file.file ),
+					altText: file.name,
+				};
+			} ),
+		];
+
+		return (
+			<div className="woocommerce-fields-field__downloadable">
+				{ items.length > 0 && (
+					<ListItem
+						items={ items }
+						onRemove={ ( item ) => handleRemoveDownload( item.id ) }
+						showRemoveButton={ true }
+					/>
+				) }
+				<FormFileUpload
+					className="woocommerce-fields-field__downloadable-upload-button"
+					onChange={ ( event ) => {
+						const file = event?.currentTarget.files?.[ 0 ];
+						if ( file ) {
+							handleAddDownload( file );
+						}
+					} }
+				>
+					<span>
+						{ __( '+ Upload another file', 'woocommerce' ) }
+					</span>
+				</FormFileUpload>
+			</div>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/downloadable/style.scss b/packages/js/experimental-products-app/src/fields/downloadable/style.scss
new file mode 100644
index 00000000000..ca21a95ed5b
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/downloadable/style.scss
@@ -0,0 +1,7 @@
+.woocommerce-fields-field__downloadable {
+	// Override the WordPress button styles.
+	button.woocommerce-fields-field__downloadable-upload-button.components-button {
+		color: #3858e9;
+		box-shadow: none;
+	}
+}
diff --git a/packages/js/experimental-products-app/src/fields/downloadable/utils.tsx b/packages/js/experimental-products-app/src/fields/downloadable/utils.tsx
new file mode 100644
index 00000000000..c278ee5bbfd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/downloadable/utils.tsx
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { Icon } from '@wordpress/ui';
+import { file as FileIcon } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import { ProductEntityRecord } from '../types';
+
+export type UploadedItem = Omit<
+	ProductEntityRecord[ 'downloads' ][ 0 ],
+	'id'
+> & {
+	id?: string;
+};
+
+export type ItemToUpload = UploadedItem & { type: string };
+
+export const getLastPathFromUrl = ( url: string ) => {
+	try {
+		const urlObj = new URL( url );
+		return urlObj.pathname.split( '/' ).pop() ?? url;
+	} catch {
+		const parts = url.split( '/' );
+		return parts[ parts.length - 1 ];
+	}
+};
+
+export const GenericThumbnail = () => (
+	<Icon style={ { width: '100%', height: '100%' } } icon={ FileIcon } />
+);
+
+export const isImageFromLink = ( link: string ) => {
+	const normalizedLink = link.toLowerCase();
+	return [ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg' ].some(
+		( extension ) => normalizedLink.endsWith( extension )
+	);
+};
+
+export const isItemToUpload = (
+	download: UploadedItem | ItemToUpload
+): download is ItemToUpload => {
+	return 'type' in download;
+};
+
+export const isUploadedItem = (
+	download: UploadedItem | ItemToUpload
+): download is UploadedItem => {
+	return ! ( 'type' in download );
+};
diff --git a/packages/js/experimental-products-app/src/fields/downloadable_count/field.tsx b/packages/js/experimental-products-app/src/fields/downloadable_count/field.tsx
new file mode 100644
index 00000000000..e61dc380921
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/downloadable_count/field.tsx
@@ -0,0 +1,41 @@
+/**
+ * External dependencies
+ */
+import { __, _n, sprintf } from '@wordpress/i18n';
+
+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 } ) => {
+		if ( ! item.downloadable ) {
+			return <span>{ __( 'Not downloadable', 'woocommerce' ) }</span>;
+		}
+
+		const downloads = Array.isArray( item.downloads ) ? item.downloads : [];
+		const count = downloads.length;
+
+		if ( count === 0 ) {
+			return <span>{ __( 'No files', 'woocommerce' ) }</span>;
+		}
+
+		const label = sprintf(
+			/* translators: %d: number of downloadable files */
+			_n( '%d file', '%d files', count, 'woocommerce' ),
+			count
+		);
+
+		return <span>{ label }</span>;
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/external_url/field.tsx b/packages/js/experimental-products-app/src/fields/external_url/field.tsx
new file mode 100644
index 00000000000..ef6b95e76e7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/external_url/field.tsx
@@ -0,0 +1,27 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'url',
+	label: __( 'External URL', 'woocommerce' ),
+	description: __(
+		'Add the affiliate link to the store where customers can buy this product.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+};
diff --git a/packages/js/experimental-products-app/src/fields/featured/field.tsx b/packages/js/experimental-products-app/src/fields/featured/field.tsx
new file mode 100644
index 00000000000..628fc11e645
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/featured/field.tsx
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'boolean',
+	label: __( 'Featured product', 'woocommerce' ),
+	description: __(
+		'Highlight this product in dedicated sections such as "Featured" or "Recommended".',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	type: 'boolean',
+	Edit: 'toggle',
+	getValue: ( { item } ) => !! item.featured,
+	setValue: ( { value } ) => ( {
+		featured: !! value,
+	} ),
+};
diff --git a/packages/js/experimental-products-app/src/fields/organization_summary/field.tsx b/packages/js/experimental-products-app/src/fields/organization_summary/field.tsx
new file mode 100644
index 00000000000..b5831ce1ef3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/organization_summary/field.tsx
@@ -0,0 +1,77 @@
+/**
+ * External dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+import { store as coreStore, type Term } from '@wordpress/core-data';
+
+import { decodeEntities } from '@wordpress/html-entities';
+
+import { useMemo } from '@wordpress/element';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const OrganizationSummary = ( { item }: { item: ProductEntityRecord } ) => {
+	const ids = useMemo(
+		() =>
+			( item.categories ?? [] )
+				.map( ( { id } ) => {
+					const numericId = Number( id );
+					return Number.isFinite( numericId ) ? numericId : null;
+				} )
+				.filter( ( idValue ): idValue is number => idValue !== null ),
+		[ item.categories ]
+	);
+	const terms = useSelect(
+		( select ) => {
+			if ( ids.length === 0 ) {
+				return [];
+			}
+			return (
+				select( coreStore ).getEntityRecords(
+					'taxonomy',
+					'product_cat',
+					{
+						include: ids,
+						per_page: ids.length,
+					}
+				) ?? []
+			);
+		},
+		[ ids ]
+	) as Term[];
+
+	if ( ! terms || terms.length === 0 ) {
+		return null;
+	}
+
+	const termNamesById = new Map(
+		terms.map( ( term ) => [ term.id, decodeEntities( term.name ) ] )
+	);
+
+	const orderedNames = ids
+		.map( ( termId ) => termNamesById.get( termId ) )
+		.filter( ( label ): label is string => Boolean( label ) );
+
+	if ( orderedNames.length === 0 ) {
+		return null;
+	}
+
+	return <span>{ orderedNames.join( ', ' ) }</span>;
+};
+
+const fieldDefinition = {
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( props ) => <OrganizationSummary { ...props } />,
+};
diff --git a/packages/js/experimental-products-app/src/fields/short_description/field.tsx b/packages/js/experimental-products-app/src/fields/short_description/field.tsx
new file mode 100644
index 00000000000..e34e48fc403
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/short_description/field.tsx
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+import { TextareaControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { convertHtmlToPlainText } from '../../utilites';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Summary', 'woocommerce' ),
+	description: __(
+		'Give customers a quick overview of your product. This appears above the full description.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => convertHtmlToPlainText( item.short_description ),
+	Edit: ( { data, onChange, field } ) => {
+		return (
+			<TextareaControl
+				label={ field.label }
+				value={ convertHtmlToPlainText( data.short_description || '' ) }
+				onChange={ ( value ) =>
+					onChange( { short_description: value } )
+				}
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/tags/field.tsx b/packages/js/experimental-products-app/src/fields/tags/field.tsx
new file mode 100644
index 00000000000..2d4f9988c5c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/tags/field.tsx
@@ -0,0 +1,50 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { TaxonomyEdit } from '../components/taxonomy-edit';
+
+const fieldDefinition = {
+	type: 'array',
+	label: __( 'Tags', 'woocommerce' ),
+	description: __(
+		'Add descriptive tags to help customers find related items while shopping.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => {
+		return item.tags.map( ( { id } ) => id.toString() );
+	},
+	setValue: ( { value }: { value: string[] } ) => {
+		return {
+			tags: value.map( ( v ) => ( {
+				id: parseInt( v, 10 ),
+			} ) ),
+		};
+	},
+	Edit: ( props: DataFormControlProps< ProductEntityRecord > ) => (
+		<TaxonomyEdit
+			{ ...props }
+			taxonomy="product_tag"
+			fieldProperty="tags"
+			searchPlaceholder={ __( 'Search or create tags', 'woocommerce' ) }
+			serverSearchThreshold={ 100 }
+			// @ts-expect-error wcSettings is a global variable injected by Woo core, and it doesn't have proper typings.
+			termCount={ window.wcSettings?.tagCount }
+		/>
+	),
+};
diff --git a/packages/js/experimental-products-app/src/fields/visibility_summary/field.tsx b/packages/js/experimental-products-app/src/fields/visibility_summary/field.tsx
new file mode 100644
index 00000000000..eebb82ae165
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/visibility_summary/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { ProductStatusBadge } from '../components/product-status-badge';
+
+const fieldDefinition = {
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( { item } ) => (
+		<div className="woocommerce-fields-field__visibility-summary">
+			<ProductStatusBadge
+				status={ item.status as ProductEntityRecord[ 'status' ] }
+			/>
+		</div>
+	),
+};
diff --git a/packages/js/experimental-products-app/src/fields/visibility_summary/style.scss b/packages/js/experimental-products-app/src/fields/visibility_summary/style.scss
new file mode 100644
index 00000000000..2a8302a0a66
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/visibility_summary/style.scss
@@ -0,0 +1,4 @@
+.woocommerce-fields-field__visibility-summary .woocommerce-status-badge {
+	background: transparent;
+	padding-right: 0;
+}
diff --git a/packages/js/experimental-products-app/src/utilites/convert-html-to-plain-text.ts b/packages/js/experimental-products-app/src/utilites/convert-html-to-plain-text.ts
new file mode 100644
index 00000000000..b951c6381a7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/utilites/convert-html-to-plain-text.ts
@@ -0,0 +1,11 @@
+/**
+ * Converts HTML string to plain text by stripping HTML tags
+ *
+ * @param htmlString - The HTML string to convert
+ * @return Plain text content without HTML tags
+ */
+export function convertHtmlToPlainText( htmlString: string ): string {
+	const tempDiv = document.createElement( 'div' );
+	tempDiv.innerHTML = htmlString;
+	return tempDiv.textContent || tempDiv.innerText || '';
+}
diff --git a/packages/js/experimental-products-app/src/utilites/index.ts b/packages/js/experimental-products-app/src/utilites/index.ts
new file mode 100644
index 00000000000..a508568be43
--- /dev/null
+++ b/packages/js/experimental-products-app/src/utilites/index.ts
@@ -0,0 +1 @@
+export { convertHtmlToPlainText } from './convert-html-to-plain-text';