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';