Commit 8edc64c123f for woocommerce
commit 8edc64c123fd53c263bca8c7349ab8c1c532e6b0
Author: Luigi Teschio <gigitux@gmail.com>
Date: Tue Apr 21 16:17:50 2026 +0200
[DataViews - All Products] Add pricing field modules (#64269)
* Add shared controls for products app fields
* Add pricing 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
---------
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-pricing b/packages/js/experimental-products-app/changelog/add-products-fields-pricing
new file mode 100644
index 00000000000..c09210746f7
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-fields-pricing
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add pricing field modules for the experimental products app
diff --git a/packages/js/experimental-products-app/src/fields/components/date-picker.tsx b/packages/js/experimental-products-app/src/fields/components/date-picker.tsx
index 34bbfe5eea7..05a20ae2d26 100644
--- a/packages/js/experimental-products-app/src/fields/components/date-picker.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/date-picker.tsx
@@ -5,7 +5,6 @@ import { InputControl } from '@wordpress/ui';
/**
* Internal dependencies
*/
-
import type { ProductEntityRecord } from '../types';
type DateFieldKey = 'date_on_sale_from' | 'date_on_sale_to';
diff --git a/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx b/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
index 4df12e55d94..ff68535b7ba 100644
--- a/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
@@ -7,11 +7,6 @@ import { __ } from '@wordpress/i18n';
import { isValidElement } from '@wordpress/element';
import { Stack } from '@wordpress/ui';
-/**
- * Internal dependencies
- */
-import './style.scss';
-
type Item = {
id: number | string;
title: string;
diff --git a/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.test.tsx b/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.test.tsx
new file mode 100644
index 00000000000..3cad58c0fd6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.test.tsx
@@ -0,0 +1,252 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/**
+ * External dependencies
+ */
+import { act, render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { fieldExtensions } from './field';
+
+let lastDatePickerProps: any;
+
+jest.mock( '../components/date-picker', () => ( {
+ DatePicker: ( props: any ) => {
+ lastDatePickerProps = props;
+ return <div data-testid="date-picker" />;
+ },
+ parseDateTimeLocal: jest.requireActual( '../components/date-picker' )
+ .parseDateTimeLocal,
+ formatDateTimeLocal: jest.requireActual( '../components/date-picker' )
+ .formatDateTimeLocal,
+} ) );
+
+const Edit = fieldExtensions.Edit!;
+const baseField = { id: 'date_on_sale_from', label: 'Sale start' } as any;
+
+const makeProduct = ( overrides: Record< string, any > = {} ) => ( {
+ date_on_sale_from: '',
+ date_on_sale_to: '',
+ ...overrides,
+} );
+
+describe( 'date_on_sale_from field', () => {
+ beforeEach( () => {
+ lastDatePickerProps = undefined;
+ jest.clearAllMocks();
+ } );
+
+ describe( 'isVisible', () => {
+ it( 'returns true when date_on_sale_from is set', () => {
+ expect(
+ fieldExtensions.isVisible!(
+ makeProduct( {
+ date_on_sale_from: '2025-03-01T00:00',
+ } ) as any,
+ {} as any
+ )
+ ).toBe( true );
+ } );
+
+ it( 'returns true when date_on_sale_to is set', () => {
+ expect(
+ fieldExtensions.isVisible!(
+ makeProduct( {
+ date_on_sale_to: '2025-03-01T00:00',
+ } ) as any,
+ {} as any
+ )
+ ).toBe( true );
+ } );
+
+ it( 'returns false when neither date is set', () => {
+ expect(
+ fieldExtensions.isVisible!( makeProduct() as any, {} as any )
+ ).toBe( false );
+ } );
+ } );
+
+ describe( 'Edit', () => {
+ it( 'passes isDateDisabled that rejects past dates', () => {
+ render(
+ <Edit
+ data={ makeProduct() as any }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const yesterday = new Date();
+ yesterday.setDate( yesterday.getDate() - 1 );
+ expect( lastDatePickerProps.isDateDisabled( yesterday ) ).toBe(
+ true
+ );
+
+ const tomorrow = new Date();
+ tomorrow.setDate( tomorrow.getDate() + 1 );
+ expect( lastDatePickerProps.isDateDisabled( tomorrow ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'passes min as today', () => {
+ render(
+ <Edit
+ data={ makeProduct() as any }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const min = lastDatePickerProps.min as Date;
+ const today = new Date();
+ today.setHours( 0, 0, 0, 0 );
+
+ expect( min.getTime() ).toBe( today.getTime() );
+ } );
+
+ it( 'calls onChange with value when no end date conflict', () => {
+ const onChange = jest.fn();
+
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-01T00:00',
+ date_on_sale_to: '2025-03-20T00:00',
+ } ) as any
+ }
+ onChange={ onChange }
+ field={ baseField }
+ />
+ );
+
+ act( () => {
+ lastDatePickerProps.onChange( {
+ date_on_sale_from: '2025-03-05T10:00',
+ } );
+ } );
+
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith( {
+ date_on_sale_from: '2025-03-05T10:00',
+ } );
+ } );
+
+ it( 'auto-adjusts end date when start >= end in a single onChange call', () => {
+ const onChange = jest.fn();
+
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-01T00:00',
+ date_on_sale_to: '2025-03-10T00:00',
+ } ) as any
+ }
+ onChange={ onChange }
+ field={ baseField }
+ />
+ );
+
+ act( () => {
+ lastDatePickerProps.onChange( {
+ date_on_sale_from: '2025-03-15T10:00',
+ } );
+ } );
+
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith( {
+ date_on_sale_from: '2025-03-15T10:00',
+ date_on_sale_to: '2025-03-16T10:00',
+ } );
+ } );
+
+ it( 'auto-adjusts end date when start equals end exactly', () => {
+ const onChange = jest.fn();
+
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-01T00:00',
+ date_on_sale_to: '2025-03-10T12:00',
+ } ) as any
+ }
+ onChange={ onChange }
+ field={ baseField }
+ />
+ );
+
+ act( () => {
+ lastDatePickerProps.onChange( {
+ date_on_sale_from: '2025-03-10T12:00',
+ } );
+ } );
+
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith( {
+ date_on_sale_from: '2025-03-10T12:00',
+ date_on_sale_to: '2025-03-11T12:00',
+ } );
+ } );
+
+ it( 'does not adjust end date when end is not set', () => {
+ const onChange = jest.fn();
+
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '',
+ date_on_sale_to: '',
+ } ) as any
+ }
+ onChange={ onChange }
+ field={ baseField }
+ />
+ );
+
+ act( () => {
+ lastDatePickerProps.onChange( {
+ date_on_sale_from: '2025-03-15T10:00',
+ } );
+ } );
+
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith( {
+ date_on_sale_from: '2025-03-15T10:00',
+ } );
+ } );
+
+ it( 'does not adjust end date when start is cleared', () => {
+ const onChange = jest.fn();
+
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-01T00:00',
+ date_on_sale_to: '2025-03-10T00:00',
+ } ) as any
+ }
+ onChange={ onChange }
+ field={ baseField }
+ />
+ );
+
+ act( () => {
+ lastDatePickerProps.onChange( {
+ date_on_sale_from: null,
+ } );
+ } );
+
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith( {
+ date_on_sale_from: null,
+ } );
+ } );
+ } );
+} );
+/* eslint-enable @typescript-eslint/no-explicit-any */
diff --git a/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.tsx b/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.tsx
new file mode 100644
index 00000000000..9319d5526ba
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.tsx
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useMemo } from '@wordpress/element';
+
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import {
+ DatePicker,
+ parseDateTimeLocal,
+ formatDateTimeLocal,
+} from '../components/date-picker';
+
+const fieldDefinition = {
+ type: 'datetime',
+ label: __( 'Start sale on', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( product ) => {
+ return !! product.date_on_sale_to || !! product.date_on_sale_from;
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const today = useMemo( () => {
+ const d = new Date();
+ d.setHours( 0, 0, 0, 0 );
+ return d;
+ }, [] );
+
+ const handleChange = useCallback(
+ ( value: { date_on_sale_from?: string | null } ) => {
+ const newStart = value.date_on_sale_from;
+ const currentEnd = data.date_on_sale_to;
+
+ if (
+ typeof newStart !== 'string' ||
+ ! newStart ||
+ typeof currentEnd !== 'string' ||
+ ! currentEnd
+ ) {
+ onChange( value );
+ return;
+ }
+
+ const startDate = parseDateTimeLocal( newStart );
+ const endDate = parseDateTimeLocal( currentEnd );
+
+ if (
+ startDate &&
+ endDate &&
+ startDate.getTime() >= endDate.getTime()
+ ) {
+ const newEndDate = new Date( startDate );
+ newEndDate.setDate( newEndDate.getDate() + 1 );
+
+ onChange( {
+ ...value,
+ date_on_sale_to: formatDateTimeLocal( newEndDate ),
+ } );
+ return;
+ }
+
+ onChange( value );
+ },
+ [ data.date_on_sale_to, onChange ]
+ );
+
+ return (
+ <DatePicker
+ data={ data }
+ onChange={ handleChange }
+ field={ field }
+ fieldKey="date_on_sale_from"
+ min={ today }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.test.tsx b/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.test.tsx
new file mode 100644
index 00000000000..65a6db92a78
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.test.tsx
@@ -0,0 +1,177 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { fieldExtensions } from './field';
+
+let lastDatePickerProps: any;
+
+jest.mock( '../components/date-picker', () => ( {
+ DatePicker: ( props: any ) => {
+ lastDatePickerProps = props;
+ return <div data-testid="date-picker" />;
+ },
+ parseDateTimeLocal: jest.requireActual( '../components/date-picker' )
+ .parseDateTimeLocal,
+} ) );
+
+const Edit = fieldExtensions.Edit!;
+const baseField = { id: 'date_on_sale_to', label: 'Sale end' } as any;
+
+const makeProduct = ( overrides: Record< string, any > = {} ) => ( {
+ date_on_sale_from: '',
+ date_on_sale_to: '',
+ ...overrides,
+} );
+
+describe( 'date_on_sale_to field', () => {
+ beforeEach( () => {
+ lastDatePickerProps = undefined;
+ jest.clearAllMocks();
+ } );
+
+ describe( 'Edit', () => {
+ it( 'sets min to start date + 1 minute when start date exists', () => {
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-15T10:00',
+ date_on_sale_to: '2025-03-20T12:00',
+ } ) as any
+ }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const min = lastDatePickerProps.min as Date;
+ expect( min.getFullYear() ).toBe( 2025 );
+ expect( min.getMonth() ).toBe( 2 ); // March
+ expect( min.getDate() ).toBe( 15 );
+ expect( min.getHours() ).toBe( 10 );
+ expect( min.getMinutes() ).toBe( 1 );
+ } );
+
+ it( 'prevents end date from equalling start date', () => {
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-15T10:00',
+ date_on_sale_to: '2025-03-20T12:00',
+ } ) as any
+ }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const min = lastDatePickerProps.min as Date;
+ const start = new Date( 2025, 2, 15, 10, 0 );
+
+ // min must be strictly after start
+ expect( min.getTime() ).toBeGreaterThan( start.getTime() );
+ } );
+
+ it( 'sets min to today when no start date', () => {
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_to: '2025-03-20T12:00',
+ } ) as any
+ }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const min = lastDatePickerProps.min as Date;
+ const today = new Date();
+ today.setHours( 0, 0, 0, 0 );
+
+ expect( min.getTime() ).toBe( today.getTime() );
+ } );
+
+ it( 'disables calendar dates before start date', () => {
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_from: '2025-03-15T10:00',
+ date_on_sale_to: '2025-03-20T12:00',
+ } ) as any
+ }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const march14 = new Date( 2025, 2, 14 );
+ const march15 = new Date( 2025, 2, 15 );
+ const march16 = new Date( 2025, 2, 16 );
+
+ expect( lastDatePickerProps.isDateDisabled( march14 ) ).toBe(
+ true
+ );
+ expect( lastDatePickerProps.isDateDisabled( march15 ) ).toBe(
+ false
+ );
+ expect( lastDatePickerProps.isDateDisabled( march16 ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'disables calendar dates before today when no start date', () => {
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_to: '2025-03-20T12:00',
+ } ) as any
+ }
+ onChange={ jest.fn() }
+ field={ baseField }
+ />
+ );
+
+ const yesterday = new Date();
+ yesterday.setDate( yesterday.getDate() - 1 );
+
+ const tomorrow = new Date();
+ tomorrow.setDate( tomorrow.getDate() + 1 );
+
+ expect( lastDatePickerProps.isDateDisabled( yesterday ) ).toBe(
+ true
+ );
+ expect( lastDatePickerProps.isDateDisabled( tomorrow ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'passes onChange directly without wrapper', () => {
+ const onChange = jest.fn();
+
+ render(
+ <Edit
+ data={
+ makeProduct( {
+ date_on_sale_to: '2025-03-20T12:00',
+ } ) as any
+ }
+ onChange={ onChange }
+ field={ baseField }
+ />
+ );
+
+ expect( lastDatePickerProps.onChange ).toBe( onChange );
+ } );
+ } );
+} );
+/* eslint-enable @typescript-eslint/no-explicit-any */
diff --git a/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.tsx b/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.tsx
new file mode 100644
index 00000000000..9048db2083f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.tsx
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from '@wordpress/element';
+
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { DatePicker, parseDateTimeLocal } from '../components/date-picker';
+
+const fieldDefinition = {
+ type: 'datetime',
+ label: __( 'End sale on', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( product ) => {
+ return !! product.date_on_sale_to || !! product.date_on_sale_from;
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const dateOnSaleFrom = useMemo(
+ () =>
+ typeof data?.date_on_sale_from === 'string' &&
+ data.date_on_sale_from
+ ? parseDateTimeLocal( data.date_on_sale_from )
+ : null,
+ [ data?.date_on_sale_from ]
+ );
+
+ const minDate = useMemo( () => {
+ if ( dateOnSaleFrom ) {
+ const min = new Date( dateOnSaleFrom );
+ min.setMinutes( min.getMinutes() + 1 );
+ return min;
+ }
+ const today = new Date();
+ today.setHours( 0, 0, 0, 0 );
+ return today;
+ }, [ dateOnSaleFrom ] );
+
+ return (
+ <DatePicker
+ data={ data }
+ onChange={ onChange }
+ field={ field }
+ fieldKey="date_on_sale_to"
+ min={ minDate }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/name/field.tsx b/packages/js/experimental-products-app/src/fields/name/field.tsx
new file mode 100644
index 00000000000..5e7644a0f7d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/name/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: __( 'Name', 'woocommerce' ),
+ enableSorting: true,
+ filterBy: false,
+ enableHiding: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ header: <span>{ __( 'Product', 'woocommerce' ) }</span>,
+ isValid: {
+ required: true,
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/on_sale/field.tsx b/packages/js/experimental-products-app/src/fields/on_sale/field.tsx
new file mode 100644
index 00000000000..ec4b96b8590
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/on_sale/field.tsx
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'boolean',
+ label: __( 'On sale', '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.on_sale || !! item.sale_price,
+ setValue: ( { value } ) =>
+ value
+ ? { on_sale: true }
+ : {
+ on_sale: false,
+ sale_price: '',
+ date_on_sale_from: null,
+ date_on_sale_to: null,
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/on_sale/test/field.test.tsx b/packages/js/experimental-products-app/src/fields/on_sale/test/field.test.tsx
new file mode 100644
index 00000000000..747393eecec
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/on_sale/test/field.test.tsx
@@ -0,0 +1,75 @@
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../../types';
+
+import { fieldExtensions } from '../field';
+
+const makeProduct = (
+ overrides: Partial< ProductEntityRecord > = {}
+): ProductEntityRecord =>
+ ( {
+ id: 1,
+ type: 'simple',
+ on_sale: false,
+ sale_price: '',
+ ...overrides,
+ } as unknown as ProductEntityRecord );
+
+describe( 'on_sale field', () => {
+ it( 'has type boolean', () => {
+ expect( fieldExtensions.type ).toBe( 'boolean' );
+ } );
+
+ describe( 'getValue', () => {
+ const { getValue } = fieldExtensions;
+
+ it( 'returns false when not on sale and no sale price', () => {
+ expect(
+ getValue!( {
+ item: makeProduct( { on_sale: false, sale_price: '' } ),
+ } )
+ ).toBe( false );
+ } );
+
+ it( 'returns true when on_sale is true', () => {
+ expect(
+ getValue!( { item: makeProduct( { on_sale: true } ) } )
+ ).toBe( true );
+ } );
+
+ it( 'returns true when sale_price is set', () => {
+ expect(
+ getValue!( {
+ item: makeProduct( {
+ on_sale: false,
+ sale_price: '9.99',
+ } ),
+ } )
+ ).toBe( true );
+ } );
+ } );
+
+ describe( 'setValue', () => {
+ const { setValue } = fieldExtensions;
+
+ it( 'sets on_sale true when enabling', () => {
+ expect( setValue!( { value: true } ) ).toEqual( {
+ on_sale: true,
+ } );
+ } );
+
+ it( 'clears sale fields when disabling', () => {
+ expect( setValue!( { value: false } ) ).toEqual( {
+ on_sale: false,
+ sale_price: '',
+ date_on_sale_from: null,
+ date_on_sale_to: null,
+ } );
+ } );
+ } );
+
+ it( 'uses the built-in toggle Edit component', () => {
+ expect( fieldExtensions.Edit ).toBe( 'toggle' );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/price/field.tsx b/packages/js/experimental-products-app/src/fields/price/field.tsx
new file mode 100644
index 00000000000..4798541ed85
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/price/field.tsx
@@ -0,0 +1,190 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useCallback } from '@wordpress/element';
+import type { Field } from '@wordpress/dataviews';
+import { InputControl, Stack, InputLayout } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+
+import { formatCurrency, getCurrencyObject } from '../utils/currency';
+
+import type { ProductEntityRecord } from '../types';
+
+import { toNumberOrNaN } from './utils';
+
+type PriceRange = [ number | string, number | string ];
+type PriceFilterData = Omit< ProductEntityRecord, 'price' > & {
+ price: string | PriceRange;
+};
+
+const isLeftPositioned = ( position: string ) =>
+ position === 'left' || position === 'left_space';
+
+const isRightPositioned = ( position: string ) =>
+ position === 'right' || position === 'right_space';
+
+const CurrencyPrefixSlot = () => {
+ const currency = getCurrencyObject();
+
+ if ( ! isLeftPositioned( currency.symbolPosition ) ) {
+ return null;
+ }
+
+ return (
+ <InputLayout.Slot padding="minimal">
+ { currency.symbol }
+ </InputLayout.Slot>
+ );
+};
+
+const CurrencySuffixSlot = () => {
+ const currency = getCurrencyObject();
+
+ if ( ! isRightPositioned( currency.symbolPosition ) ) {
+ return null;
+ }
+
+ return (
+ <InputLayout.Slot padding="minimal">
+ { currency.symbol }
+ </InputLayout.Slot>
+ );
+};
+
+const fieldDefinition = {
+ label: __( 'Price', 'woocommerce' ),
+ enableSorting: true,
+ filterBy: {
+ operators: [ 'is', 'between', 'greaterThanOrEqual', 'lessThanOrEqual' ],
+ },
+} satisfies Partial< Field< PriceFilterData > >;
+
+export const fieldExtensions: Partial< Field< PriceFilterData > > = {
+ ...fieldDefinition,
+ getValueFormatted: ( { item, field } ) => {
+ const value: unknown = field.getValue( { item } );
+ if ( value === null || value === undefined || value === '' ) {
+ // Return em-dash for empty values to prevent DataViews fallback to "undefined"
+ return '\u2014';
+ }
+ const num = toNumberOrNaN( value );
+ if ( Number.isNaN( num ) ) {
+ // Return em-dash for NaN values to prevent DataViews fallback to "NaN"
+ return '\u2014';
+ }
+ const currency = getCurrencyObject();
+ return formatCurrency( num, currency.code );
+ },
+ render: ( { item } ) => {
+ const rawPrice = item.price;
+ const price = toNumberOrNaN( rawPrice );
+
+ // Show an em dash when no sale price is provided instead of leaving the cell blank.
+ if (
+ rawPrice === undefined ||
+ rawPrice === null ||
+ rawPrice === '' ||
+ Number.isNaN( price )
+ ) {
+ return <span>{ '\u2014' }</span>;
+ }
+
+ const currency = getCurrencyObject();
+ const regularPrice = toNumberOrNaN( item.regular_price );
+
+ // Only render the strikethrough when the regular price is a valid number,
+ // since partially saved products may send empty or NaN-like values here.
+ if ( item.on_sale && Number.isFinite( regularPrice ) ) {
+ return (
+ <Stack direction="row">
+ <s>{ formatCurrency( regularPrice, currency.code ) }</s>
+ <span>{ formatCurrency( price, currency.code ) }</span>
+ </Stack>
+ );
+ }
+
+ return <span>{ formatCurrency( price, currency.code ) }</span>;
+ },
+ Edit: ( { data, onChange, hideLabelFromVision, operator, field } ) => {
+ const currency = getCurrencyObject();
+ const step = Math.pow( 10, -currency.precision );
+ const [ minValue = '', maxValue = '' ] = Array.isArray( data.price )
+ ? data.price
+ : [];
+ const min = String( minValue );
+ const max = String( maxValue );
+
+ const onChangeMin = useCallback(
+ ( newValue: string | undefined ) => {
+ onChange( {
+ price: [ parseFloat( newValue || '' ), max ] as [
+ number | string,
+ number | string
+ ],
+ } );
+ },
+ [ onChange, max ]
+ );
+
+ const onChangeMax = useCallback(
+ ( newValue: string | undefined ) => {
+ onChange( {
+ price: [ min, parseFloat( newValue || '' ) ] as [
+ number | string,
+ number | string
+ ],
+ } );
+ },
+ [ onChange, min ]
+ );
+
+ if ( operator === 'between' ) {
+ return (
+ <Stack direction="row">
+ <InputControl
+ label={ __( 'From', 'woocommerce' ) }
+ type="number"
+ step={ step }
+ value={ min }
+ prefix={ <CurrencyPrefixSlot /> }
+ suffix={ <CurrencySuffixSlot /> }
+ onChange={ ( event ) =>
+ onChangeMin( event.target.value )
+ }
+ />
+ <InputControl
+ label={ __( 'To', 'woocommerce' ) }
+ type="number"
+ step={ step }
+ value={ max }
+ prefix={ <CurrencyPrefixSlot /> }
+ suffix={ <CurrencySuffixSlot /> }
+ onChange={ ( event ) =>
+ onChangeMax( event.target.value )
+ }
+ />
+ </Stack>
+ );
+ }
+
+ const singleValue = typeof data.price === 'string' ? data.price : '';
+
+ return (
+ <InputControl
+ label={ hideLabelFromVision ? '' : field.label }
+ type="number"
+ step={ step }
+ value={ singleValue }
+ prefix={ <CurrencyPrefixSlot /> }
+ suffix={ <CurrencySuffixSlot /> }
+ onChange={ ( event ) =>
+ onChange( { price: event.target.value } )
+ }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/price_summary/field.test.tsx b/packages/js/experimental-products-app/src/fields/price_summary/field.test.tsx
new file mode 100644
index 00000000000..c788b177949
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/price_summary/field.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * 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( 'price_summary render not implemented' );
+ }
+
+ const Render = fieldExtensions.render as React.ComponentType< {
+ item: Partial< ProductEntityRecord >;
+ } >;
+
+ return render( React.createElement( Render, { item } ) );
+};
+
+describe( 'price_summary field', () => {
+ beforeEach( () => {
+ document.documentElement.lang = 'en-US';
+ } );
+
+ it( 'shows the sale price and label when the product is on sale', () => {
+ renderSummary( {
+ on_sale: true,
+ regular_price: '150',
+ sale_price: '100',
+ } );
+
+ expect( screen.getByText( '$100.00 · On sale' ) ).toBeInTheDocument();
+ } );
+
+ it( 'falls back to the regular price when the product is not on sale', () => {
+ renderSummary( {
+ on_sale: false,
+ regular_price: '99',
+ } );
+
+ expect( screen.getByText( '$99.00' ) ).toBeInTheDocument();
+ expect( screen.queryByText( /On sale/i ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders nothing when no price data is available', () => {
+ const { container } = renderSummary( {} );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/price_summary/field.tsx b/packages/js/experimental-products-app/src/fields/price_summary/field.tsx
new file mode 100644
index 00000000000..5872950d30d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/price_summary/field.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { getCurrencyObject } from '../utils/currency';
+
+import type { ProductEntityRecord } from '../types';
+
+import { toNumberOrNaN } from '../price/utils';
+
+const formatPrice = ( amount: number, currencyCode: string ) => {
+ const locale = document.documentElement.lang || 'en-US';
+ return new Intl.NumberFormat( locale, {
+ style: 'currency',
+ currency: currencyCode,
+ } ).format( amount );
+};
+
+const fieldDefinition = {
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ render: ( { item } ) => {
+ const salePrice = toNumberOrNaN( item.sale_price );
+ const regularPrice = toNumberOrNaN( item.regular_price );
+ const hasSalePrice =
+ Boolean( item.on_sale ) && Number.isFinite( salePrice );
+
+ const priceToDisplay = hasSalePrice ? salePrice : regularPrice;
+
+ if ( ! Number.isFinite( priceToDisplay ) ) {
+ return null;
+ }
+
+ const currency = getCurrencyObject();
+ const priceText = formatPrice( priceToDisplay, currency.code );
+
+ if ( hasSalePrice ) {
+ return (
+ <span>
+ { priceText }
+ { ' · ' }
+ { __( 'On sale', 'woocommerce' ) }
+ </span>
+ );
+ }
+
+ return <span>{ priceText }</span>;
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/product_status/field.tsx b/packages/js/experimental-products-app/src/fields/product_status/field.tsx
new file mode 100644
index 00000000000..19a2afbf09a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/product_status/field.tsx
@@ -0,0 +1,68 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+import { SelectControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { ProductStatusBadge } from '../components/product-status-badge';
+
+function isValidStatus( value: string ) {
+ return value === 'draft' || value === 'publish' || value === 'trash';
+}
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Status', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ elements: [
+ { value: 'draft', label: __( 'Draft', 'woocommerce' ) },
+ { value: 'publish', label: __( 'Active', 'woocommerce' ) },
+ { value: 'trash', label: __( 'Trash', 'woocommerce' ) },
+ ],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ getValue: ( { item } ) => item.status,
+ render: ( { item }: { item: ProductEntityRecord } ) => (
+ <ProductStatusBadge status={ item.status } />
+ ),
+ Edit: ( { data, onChange, field } ) => {
+ const description =
+ data.status === 'publish'
+ ? __(
+ 'This product is live and visible on your store.',
+ 'woocommerce'
+ )
+ : __(
+ 'This product is not visible to customers.',
+ 'woocommerce'
+ );
+
+ return (
+ <SelectControl
+ label={ field.label }
+ help={ description }
+ value={ data.status }
+ options={ field.elements?.filter(
+ ( element: { label: string; value: string } ) =>
+ element.value !== 'trash'
+ ) }
+ onChange={ ( value ) => {
+ if ( value && isValidStatus( value ) ) {
+ onChange( {
+ status: value,
+ } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/regular_price/field.tsx b/packages/js/experimental-products-app/src/fields/regular_price/field.tsx
new file mode 100644
index 00000000000..2561525b1b1
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/regular_price/field.tsx
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { type ProductEntityRecord } from '../types';
+
+import { CurrencyInput } from '../components/currency-input';
+
+import { validatePrice } from '../price/utils';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Regular Price', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isValid: {
+ custom: ( item ) => validatePrice( item.regular_price ),
+ },
+ Edit: CurrencyInput,
+};
diff --git a/packages/js/experimental-products-app/src/fields/sale_price/field.tsx b/packages/js/experimental-products-app/src/fields/sale_price/field.tsx
new file mode 100644
index 00000000000..c13def90fa7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/sale_price/field.tsx
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { type ProductEntityRecord } from '../types';
+
+import { CurrencyInput } from '../components/currency-input';
+
+import { validateSalePrice } from './validation';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Sale Price', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( item ) => {
+ return !! item.on_sale || !! item.sale_price;
+ },
+ isValid: {
+ custom: ( item ) => validateSalePrice( item ),
+ },
+ Edit: CurrencyInput,
+};
diff --git a/packages/js/experimental-products-app/src/fields/sale_price/test/field.test.ts b/packages/js/experimental-products-app/src/fields/sale_price/test/field.test.ts
new file mode 100644
index 00000000000..1d14fe35f60
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/sale_price/test/field.test.ts
@@ -0,0 +1,110 @@
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../../types';
+
+import { validatePrice } from '../../price/utils';
+
+import { validateSalePrice } from '../validation';
+
+function makeItem(
+ overrides: Partial< ProductEntityRecord > = {}
+): ProductEntityRecord {
+ return {
+ regular_price: '',
+ sale_price: '',
+ on_sale: false,
+ ...overrides,
+ } as ProductEntityRecord;
+}
+
+describe( 'validatePrice', () => {
+ it( 'returns null for empty string', () => {
+ expect( validatePrice( '' ) ).toBeNull();
+ } );
+
+ it( 'returns null for whitespace-only string', () => {
+ expect( validatePrice( ' ' ) ).toBeNull();
+ } );
+
+ it( 'returns null for undefined', () => {
+ expect( validatePrice( undefined ) ).toBeNull();
+ } );
+
+ it( 'returns null for null', () => {
+ expect( validatePrice( null ) ).toBeNull();
+ } );
+
+ it( 'returns null for valid integer', () => {
+ expect( validatePrice( '10' ) ).toBeNull();
+ } );
+
+ it( 'returns null for valid decimal', () => {
+ expect( validatePrice( '9.99' ) ).toBeNull();
+ } );
+
+ it( 'returns null for zero', () => {
+ expect( validatePrice( '0' ) ).toBeNull();
+ } );
+
+ it( 'returns error for non-numeric string', () => {
+ expect( validatePrice( 'abc' ) ).toBe( 'Please enter a valid price.' );
+ } );
+
+ it( 'returns error for negative number', () => {
+ expect( validatePrice( '-5' ) ).toBe( 'Price must not be negative.' );
+ } );
+} );
+
+describe( 'validateSalePrice', () => {
+ it( 'returns null when both prices are empty', () => {
+ expect( validateSalePrice( makeItem() ) ).toBeNull();
+ } );
+
+ it( 'returns null when sale price is empty', () => {
+ const item = makeItem( { regular_price: '10' } );
+ expect( validateSalePrice( item ) ).toBeNull();
+ } );
+
+ it( 'returns null when regular price is empty', () => {
+ const item = makeItem( { sale_price: '5' } );
+ expect( validateSalePrice( item ) ).toBeNull();
+ } );
+
+ it( 'returns null when sale price is lower than regular price', () => {
+ const item = makeItem( { regular_price: '20', sale_price: '15' } );
+ expect( validateSalePrice( item ) ).toBeNull();
+ } );
+
+ it( 'returns error when sale price equals regular price', () => {
+ const item = makeItem( { regular_price: '20', sale_price: '20' } );
+ expect( validateSalePrice( item ) ).toBe(
+ 'Sale price must be lower than the regular price.'
+ );
+ } );
+
+ it( 'returns error when sale price exceeds regular price', () => {
+ const item = makeItem( { regular_price: '10', sale_price: '15' } );
+ expect( validateSalePrice( item ) ).toBe(
+ 'Sale price must be lower than the regular price.'
+ );
+ } );
+
+ it( 'handles decimal prices correctly', () => {
+ const item = makeItem( {
+ regular_price: '9.99',
+ sale_price: '9.98',
+ } );
+ expect( validateSalePrice( item ) ).toBeNull();
+ } );
+
+ it( 'returns error for equal decimal prices', () => {
+ const item = makeItem( {
+ regular_price: '9.99',
+ sale_price: '9.99',
+ } );
+ expect( validateSalePrice( item ) ).toBe(
+ 'Sale price must be lower than the regular price.'
+ );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/sale_price/validation.ts b/packages/js/experimental-products-app/src/fields/sale_price/validation.ts
new file mode 100644
index 00000000000..95ec9d659f4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/sale_price/validation.ts
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { toNumberOrNaN, validatePrice } from '../price/utils';
+
+export function validateSalePrice( item: ProductEntityRecord ): string | null {
+ // First check if sale price is a valid number.
+ const numericError = validatePrice( item.sale_price );
+ if ( numericError ) {
+ return numericError;
+ }
+
+ const regularPrice = toNumberOrNaN( item.regular_price );
+
+ // Only compare when regular price is also numeric.
+ if ( Number.isNaN( regularPrice ) ) {
+ return null;
+ }
+
+ const salePrice = toNumberOrNaN( item.sale_price );
+ if ( salePrice >= regularPrice ) {
+ return __(
+ 'Sale price must be lower than the regular price.',
+ 'woocommerce'
+ );
+ }
+
+ return null;
+}
diff --git a/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx b/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx
new file mode 100644
index 00000000000..b657caad17e
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx
@@ -0,0 +1,95 @@
+/**
+ * External dependencies
+ */
+import {
+ BaseControl,
+ FlexBlock,
+ FormToggle,
+ __experimentalHStack as HStack,
+} from '@wordpress/components';
+
+import { useInstanceId } from '@wordpress/compose';
+
+import { useState } from '@wordpress/element';
+
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { getLocalDefaultSaleStart } from '../price/utils';
+
+const fieldDefinition = {
+ type: 'boolean',
+ label: __( 'Schedule sale', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( item ) => {
+ return !! item.on_sale || !! item.sale_price;
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const toggleId = useInstanceId( FormToggle, 'schedule-sale-toggle' );
+ const [ tempDateOnSaleFrom, setTempDateOnSaleFrom ] = useState(
+ data.date_on_sale_from || ''
+ );
+ const [ tempDateOnSaleTo, setTempDateOnSaleTo ] = useState(
+ data.date_on_sale_to || ''
+ );
+ const checked = !! data.date_on_sale_to || !! data.date_on_sale_from;
+ return (
+ <BaseControl className="components-toggle-control">
+ <HStack justify="flex-start" spacing={ 2 }>
+ <FormToggle
+ id={ toggleId }
+ checked={ checked }
+ onChange={ () => {
+ if ( checked ) {
+ setTempDateOnSaleFrom(
+ data.date_on_sale_from || ''
+ );
+ setTempDateOnSaleTo(
+ data.date_on_sale_to || ''
+ );
+ onChange( {
+ date_on_sale_from: '',
+ date_on_sale_to: '',
+ } );
+ } else {
+ let dateOnSaleFrom =
+ data.date_on_sale_from ||
+ tempDateOnSaleFrom;
+ const dateOnSaleTo =
+ data.date_on_sale_to || tempDateOnSaleTo;
+
+ if ( ! dateOnSaleFrom && ! dateOnSaleTo ) {
+ dateOnSaleFrom = getLocalDefaultSaleStart();
+ }
+
+ onChange( {
+ date_on_sale_from: dateOnSaleFrom,
+ date_on_sale_to: dateOnSaleTo,
+ } );
+ }
+ } }
+ />
+ <FlexBlock
+ as="label"
+ htmlFor={ toggleId }
+ className="components-toggle-control__label"
+ >
+ { field.label }
+ </FlexBlock>
+ </HStack>
+ </BaseControl>
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/sku/field.tsx b/packages/js/experimental-products-app/src/fields/sku/field.tsx
new file mode 100644
index 00000000000..beb588d0f0a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/sku/field.tsx
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'SKU', 'woocommerce' ),
+ description: __(
+ 'Give your product a unique code to help identify it in orders and fulfilment.',
+ 'woocommerce'
+ ),
+ enableSorting: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ render: ( { item } ) => {
+ const sku = item.sku;
+
+ if ( sku === undefined || sku === null || sku === '' ) {
+ return <span>{ '\u2014' }</span>;
+ }
+
+ return <span>{ sku }</span>;
+ },
+};
diff --git a/packages/js/experimental-products-app/tsconfig.json b/packages/js/experimental-products-app/tsconfig.json
index 205e7551412..376e1607afd 100644
--- a/packages/js/experimental-products-app/tsconfig.json
+++ b/packages/js/experimental-products-app/tsconfig.json
@@ -8,11 +8,15 @@
"declarationMap": true,
"declarationDir": "./build-types",
"resolveJsonModule": true,
- "typeRoots": [ "./typings", "./node_modules/@types" ],
"jsx": "react-jsx",
"jsxFactory": null,
"jsxFragmentFactory": null
},
"include": [ "src/**/*" ],
- "exclude": [ "**/test/**", "**/stories/**" ]
+ "exclude": [
+ "**/test/**",
+ "**/stories/**",
+ "**/*.test.ts",
+ "**/*.test.tsx"
+ ]
}