Commit c7d5c87a182 for woocommerce
commit c7d5c87a182e521916efcf8631b580cbc32d38fa
Author: Luigi Teschio <gigitux@gmail.com>
Date: Tue Apr 21 17:05:07 2026 +0200
[DataViews - All Products] Add inventory field modules (#64270)
* Add shared controls for products app fields
* Add pricing fields to products app
* Add inventory 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 unit test
---------
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-inventory-shipping b/packages/js/experimental-products-app/changelog/add-products-fields-inventory-shipping
new file mode 100644
index 00000000000..7bc4c3fdfe2
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-fields-inventory-shipping
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add inventory and shipping field modules for the experimental products app
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
deleted file mode 100644
index 3cad58c0fd6..00000000000
--- a/packages/js/experimental-products-app/src/fields/date_on_sale_from/field.test.tsx
+++ /dev/null
@@ -1,252 +0,0 @@
-/* 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_to/field.test.tsx b/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.test.tsx
deleted file mode 100644
index 65a6db92a78..00000000000
--- a/packages/js/experimental-products-app/src/fields/date_on_sale_to/field.test.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-/* 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/height/field.tsx b/packages/js/experimental-products-app/src/fields/height/field.tsx
new file mode 100644
index 00000000000..dca06c38211
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/height/field.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { createDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Height', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ ...createDimensionField( 'height' ),
+};
diff --git a/packages/js/experimental-products-app/src/fields/inventory_summary/field.tsx b/packages/js/experimental-products-app/src/fields/inventory_summary/field.tsx
new file mode 100644
index 00000000000..c15ad34dc42
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/inventory_summary/field.tsx
@@ -0,0 +1,57 @@
+/**
+ * External dependencies
+ */
+import { __, 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,
+ elements: [
+ { label: __( 'In stock', 'woocommerce' ), value: 'instock' },
+ { label: __( 'Out of stock', 'woocommerce' ), value: 'outofstock' },
+ { label: __( 'On backorder', 'woocommerce' ), value: 'onbackorder' },
+ ],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ render: ( { item, field } ) => {
+ const status = item.stock_status?.toLowerCase?.() ?? 'instock';
+ const match = field.elements?.find(
+ ( element: { value: string } ) => element.value === status
+ );
+
+ if ( item.manage_stock ) {
+ if ( ! Number.isFinite( item.stock_quantity ) ) {
+ return (
+ <div className="woocommerce-fields-field__inventory-summary">
+ { __( 'No stock quantity set', 'woocommerce' ) }
+ </div>
+ );
+ }
+
+ return (
+ <div className="woocommerce-fields-field__inventory-summary">
+ { sprintf(
+ /* translators: %d: stock quantity */
+ __( '%d available in stock', 'woocommerce' ),
+ item.stock_quantity
+ ) }
+ </div>
+ );
+ }
+ return (
+ <div className="woocommerce-fields-field__inventory-summary">
+ { match?.label ?? item.stock_status }
+ </div>
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/length/field.tsx b/packages/js/experimental-products-app/src/fields/length/field.tsx
new file mode 100644
index 00000000000..7cba4ce2c45
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/length/field.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { createDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Length', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ ...createDimensionField( 'length' ),
+};
diff --git a/packages/js/experimental-products-app/src/fields/manage_stock/field.tsx b/packages/js/experimental-products-app/src/fields/manage_stock/field.tsx
new file mode 100644
index 00000000000..a4c82e4b217
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/manage_stock/field.tsx
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'boolean',
+ label: __( 'Track inventory', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ type: 'boolean',
+ Edit: 'toggle',
+};
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
deleted file mode 100644
index 747393eecec..00000000000
--- a/packages/js/experimental-products-app/src/fields/on_sale/test/field.test.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * 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_summary/field.test.tsx b/packages/js/experimental-products-app/src/fields/price_summary/field.test.tsx
deleted file mode 100644
index c788b177949..00000000000
--- a/packages/js/experimental-products-app/src/fields/price_summary/field.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * 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/shipping_class/field.tsx b/packages/js/experimental-products-app/src/fields/shipping_class/field.tsx
new file mode 100644
index 00000000000..4e174986834
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/shipping_class/field.tsx
@@ -0,0 +1,78 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
+import { SelectControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+
+import type { ProductEntityRecord } from '../types';
+
+interface ProductShippingClass {
+ id: number;
+ slug: string;
+ name: string;
+ description: string;
+ count: number;
+}
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Shipping Class', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ id: 'shipping_class',
+ label: __( 'Shipping Class', 'woocommerce' ),
+ enableSorting: false,
+ type: 'text',
+ getValue: ( { item } ) => item.shipping_class,
+ Edit: ( { data, onChange, field } ) => {
+ const { shippingClasses } = useSelect( ( select ) => {
+ // TODO: Register shipping class entity and use it instead.
+ // eslint-disable-next-line @wordpress/data-no-store-string-literals
+ const { getProductShippingClasses } = select(
+ 'experimental/wc/admin/products/shipping-classes'
+ );
+ return {
+ shippingClasses:
+ // @ts-expect-error - The store return type lives in Woo core.
+ getProductShippingClasses() as ProductShippingClass[],
+ };
+ }, [] );
+
+ const options = [
+ {
+ label: __( 'No shipping class', 'woocommerce' ),
+ value: '',
+ },
+ ...( shippingClasses?.length
+ ? shippingClasses.map( ( shippingClass ) => ( {
+ label: shippingClass.name,
+ value: shippingClass.slug,
+ } ) )
+ : [] ),
+ ];
+
+ return (
+ <SelectControl
+ label={ field.label }
+ value={ data.shipping_class }
+ options={ options }
+ onChange={ ( value ) =>
+ onChange( {
+ shipping_class: value,
+ } )
+ }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/shipping_summary/field.tsx b/packages/js/experimental-products-app/src/fields/shipping_summary/field.tsx
new file mode 100644
index 00000000000..bc26afa8f7c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/shipping_summary/field.tsx
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import { useEntityRecord } from '@wordpress/core-data';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, SettingsEntityRecord } from '../types';
+
+const ShippingSummaryRender = ( { item }: { item: ProductEntityRecord } ) => {
+ const {
+ record: storeProductsSettings,
+ isResolving: storeProductsSettingsResolving,
+ } = useEntityRecord< SettingsEntityRecord >(
+ 'root',
+ 'settings',
+ 'products'
+ );
+
+ if ( storeProductsSettingsResolving ) {
+ return null;
+ }
+
+ const length = item.dimensions?.length;
+ const width = item.dimensions?.width;
+ const height = item.dimensions?.height;
+ const weight = item.weight;
+ const weightUnit = storeProductsSettings?.values?.woocommerce_weight_unit;
+ const dimensionUnit =
+ storeProductsSettings?.values?.woocommerce_dimension_unit;
+
+ // Return null if no shipping info available
+ if ( ! length && ! width && ! height && ! weight ) {
+ return null;
+ }
+
+ const parts: string[] = [];
+
+ if ( length && width && height ) {
+ parts.push(
+ `${ length } ${ dimensionUnit } x ${ width } ${ dimensionUnit } x ${ height } ${ dimensionUnit }`
+ );
+ }
+
+ if ( weight ) {
+ parts.push( `${ weight } ${ weightUnit }` );
+ }
+
+ return <span>{ parts.join( ' · ' ) }</span>;
+};
+
+const fieldDefinition = {
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ render: ( { item } ) => <ShippingSummaryRender item={ item } />,
+};
diff --git a/packages/js/experimental-products-app/src/fields/stock/field.tsx b/packages/js/experimental-products-app/src/fields/stock/field.tsx
new file mode 100644
index 00000000000..ed33eeb476a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/stock/field.tsx
@@ -0,0 +1,74 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+function isValidStockStatus( value: string ) {
+ return (
+ value === 'instock' || value === 'outofstock' || value === 'onbackorder'
+ );
+}
+
+const fieldDefinition = {
+ label: __( 'Stock', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: {
+ operators: [ 'is' ],
+ },
+ elements: [
+ { label: __( 'In stock', 'woocommerce' ), value: 'instock' },
+ { label: __( 'Out of stock', 'woocommerce' ), value: 'outofstock' },
+ { label: __( 'On backorder', 'woocommerce' ), value: 'onbackorder' },
+ ],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( item ) => {
+ return ! item.manage_stock;
+ },
+ getValue: ( { item } ) => item.stock_status,
+ render: ( { item, field } ) => {
+ const match = field?.elements?.find(
+ ( status ) => status.value === item.stock_status
+ );
+ return match ? (
+ <div className="woocommerce-fields-field__stock">
+ <span
+ className={ `woocommerce-fields-field__stock-label woocommerce-fields-field__stock-label--${ match.value }` }
+ >
+ { match.label }
+ </span>
+ { item.stock_quantity && item.stock_quantity > 0 && (
+ <span className="woocommerce-fields-field__stock-quantity">
+ ({ item.stock_quantity })
+ </span>
+ ) }
+ </div>
+ ) : (
+ item.stock_status
+ );
+ },
+ Edit: ( { data, onChange, field } ) => (
+ <SelectControl
+ label={ __( 'Status', 'woocommerce' ) }
+ value={ data.stock_status }
+ options={ field?.elements || [] }
+ onChange={ ( value ) => {
+ if ( value && isValidStockStatus( value ) ) {
+ onChange( {
+ stock_status: value,
+ } );
+ }
+ } }
+ />
+ ),
+};
diff --git a/packages/js/experimental-products-app/src/fields/stock/style.scss b/packages/js/experimental-products-app/src/fields/stock/style.scss
new file mode 100644
index 00000000000..9d1d8a3a1f2
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/stock/style.scss
@@ -0,0 +1,14 @@
+.woocommerce-fields-field__stock {
+ display: flex;
+ gap: 5px;
+
+ .woocommerce-fields-field__stock-label {
+ &--outofstock {
+ color: #cc1818;
+ }
+
+ &--onbackorder {
+ color: #ffa500;
+ }
+ }
+}
diff --git a/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx b/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
new file mode 100644
index 00000000000..a5bd56b7bc1
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'integer',
+ label: __( 'Available stock', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( item ) => {
+ return !! item.manage_stock;
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/tax_status/field.tsx b/packages/js/experimental-products-app/src/fields/tax_status/field.tsx
new file mode 100644
index 00000000000..a21630fa28d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/tax_status/field.tsx
@@ -0,0 +1,38 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ label: __( 'Tax Status', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ enableSorting: false,
+ Edit: ( { data, onChange, field } ) => {
+ return (
+ <CheckboxControl
+ label={ field.label }
+ checked={ data.tax_status === 'taxable' }
+ onChange={ () =>
+ onChange( {
+ tax_status:
+ data.tax_status === 'taxable' ? 'none' : 'taxable',
+ } )
+ }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/weight/field.tsx b/packages/js/experimental-products-app/src/fields/weight/field.tsx
new file mode 100644
index 00000000000..ec53e70ff5c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/weight/field.tsx
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useEntityRecord } from '@wordpress/core-data';
+import { InputControl, InputLayout } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+
+import type { ProductEntityRecord, SettingsEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Weight', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ label: __( 'Weight', 'woocommerce' ),
+ Edit: ( { data, onChange, field } ) => {
+ const {
+ record: storeProductsSettings,
+ isResolving: storeProductsSettingsResolving,
+ } = useEntityRecord< SettingsEntityRecord >(
+ 'root',
+ 'settings',
+ 'products'
+ );
+
+ if ( storeProductsSettingsResolving ) {
+ return null;
+ }
+
+ const weightUnit =
+ storeProductsSettings?.values?.woocommerce_weight_unit;
+
+ return (
+ <InputControl
+ label={ field.label }
+ value={ data.weight }
+ onChange={ ( event ) =>
+ onChange( { weight: event.target.value } )
+ }
+ type="number"
+ min={ 0 }
+ step="any"
+ suffix={
+ <InputLayout.Slot padding="minimal">
+ { weightUnit }
+ </InputLayout.Slot>
+ }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/fields/width/field.tsx b/packages/js/experimental-products-app/src/fields/width/field.tsx
new file mode 100644
index 00000000000..1b72dc4f440
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/width/field.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { createDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Width', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ ...createDimensionField( 'width' ),
+};