Commit 95c3714aeb9 for woocommerce

commit 95c3714aeb9332e7e6a66bcde35aab5fc3a06063
Author: verofasulo <98944206+verofasulo@users.noreply.github.com>
Date:   Thu May 14 12:32:25 2026 +0200

    Add Stock quantity filter to the experimental products app product list (#64827)

    * Add Stock quantity filter and rename the field to match

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * Fix stock_quantity filter: add Edit component and expand operators to 5

    - Add custom Edit component (InputControl from @wordpress/ui) to the
      stock_quantity field so DataViews renders a functional numeric input
      when the filter panel opens.
    - Expand filterBy.operators from [between, greaterThanOrEqual,
      lessThanOrEqual] to [is, greaterThan, greaterThanOrEqual, lessThan,
      lessThanOrEqual]; drop between (no matching WC REST param).
    - Rewrite applyStockQuantityFilter to handle all 5 operators; strict
      inequalities (greaterThan / lessThan) use ±1 offset on integers.
    - Replace the 3 existing stock_quantity query tests with 5 new ones
      covering every supported operator.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * Add isNot and between operators to stock_quantity filter

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * lint code

    * remove not supported operator

    ---------

    Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
    Co-authored-by: Luigi Teschio <gigitux@gmail.com>

diff --git a/packages/js/experimental-products-app/changelog/add-products-app-stock-quantity-filter b/packages/js/experimental-products-app/changelog/add-products-app-stock-quantity-filter
new file mode 100644
index 00000000000..d2b2b2c34d2
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-app-stock-quantity-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a Stock quantity filter to the experimental products app product list, and rename the field from "Available stock" to "Stock quantity".
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
index a5bd56b7bc1..ebd40217699 100644
--- a/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
@@ -2,20 +2,48 @@
  * External dependencies
  */
 import { __ } from '@wordpress/i18n';
-
-import type { Field } from '@wordpress/dataviews';
+import { InputControl, Stack } from '@wordpress/ui';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';

 /**
  * Internal dependencies
  */
 import type { ProductEntityRecord } from '../types';

+type StockQuantityRange = [ number | string, number | string ];
+type StockQuantityFilterRecord = Omit<
+	ProductEntityRecord,
+	'stock_quantity'
+> & {
+	stock_quantity?: number | null | StockQuantityRange;
+};
+
+const castValueToString = (
+	value: number | string | null | StockQuantityRange | undefined
+): string => {
+	if ( typeof value === 'number' ) {
+		return String( value );
+	} else if ( typeof value === 'string' ) {
+		return value;
+	}
+	return '';
+};
+
 const fieldDefinition = {
 	type: 'integer',
-	label: __( 'Available stock', 'woocommerce' ),
+	label: __( 'Stock quantity', 'woocommerce' ),
 	enableSorting: false,
 	enableHiding: false,
-	filterBy: false,
+	filterBy: {
+		operators: [
+			'is',
+			'greaterThan',
+			'greaterThanOrEqual',
+			'lessThan',
+			'lessThanOrEqual',
+			'between',
+		],
+	},
 } satisfies Partial< Field< ProductEntityRecord > >;

 export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
@@ -23,4 +51,71 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 	isVisible: ( item ) => {
 		return !! item.manage_stock;
 	},
+	Edit: ( {
+		data,
+		onChange,
+		hideLabelFromVision,
+		operator,
+		field,
+	}: DataFormControlProps< ProductEntityRecord > ) => {
+		const onChangeBetween = onChange as (
+			data: Partial< StockQuantityFilterRecord >
+		) => void;
+		const raw = ( data as StockQuantityFilterRecord ).stock_quantity;
+
+		if ( operator === 'between' ) {
+			const [ minRaw = '', maxRaw = '' ] = Array.isArray( raw )
+				? raw
+				: [];
+			const min = String( minRaw );
+			const max = String( maxRaw );
+
+			return (
+				<Stack direction="row">
+					<InputControl
+						label={ __( 'From', 'woocommerce' ) }
+						type="number"
+						step={ 1 }
+						value={ min }
+						onChange={ ( event ) => {
+							const next = event.target.value;
+							const nextMin = next === '' ? '' : Number( next );
+							onChangeBetween( {
+								stock_quantity: [ nextMin, max ],
+							} );
+						} }
+					/>
+					<InputControl
+						label={ __( 'To', 'woocommerce' ) }
+						type="number"
+						step={ 1 }
+						value={ max }
+						onChange={ ( event ) => {
+							const next = event.target.value;
+							const nextMax = next === '' ? '' : Number( next );
+							onChangeBetween( {
+								stock_quantity: [ min, nextMax ],
+							} );
+						} }
+					/>
+				</Stack>
+			);
+		}
+
+		const value = castValueToString( raw );
+		return (
+			<InputControl
+				label={ hideLabelFromVision ? '' : field.label }
+				type="number"
+				step={ 1 }
+				value={ value }
+				onChange={ ( event ) => {
+					const next = event.target.value;
+					onChange( {
+						stock_quantity: next === '' ? null : Number( next ),
+					} );
+				} }
+			/>
+		);
+	},
 };
diff --git a/packages/js/experimental-products-app/src/product-list/query.test.ts b/packages/js/experimental-products-app/src/product-list/query.test.ts
index 7a03de36045..91c6c30de2e 100644
--- a/packages/js/experimental-products-app/src/product-list/query.test.ts
+++ b/packages/js/experimental-products-app/src/product-list/query.test.ts
@@ -232,4 +232,101 @@ describe( 'buildProductListQuery', () => {

 		expect( query.exclude_shipping_class ).toEqual( [ 3, 4 ] );
 	} );
+	it( 'maps the stock_quantity is filter to both min and max', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [ { field: 'stock_quantity', operator: 'is', value: 5 } ],
+		} as View );
+
+		expect( query.min_stock_quantity ).toEqual( '5' );
+		expect( query.max_stock_quantity ).toEqual( '5' );
+	} );
+
+	it( 'maps the stock_quantity greaterThan filter to min_stock_quantity + 1', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{ field: 'stock_quantity', operator: 'greaterThan', value: 5 },
+			],
+		} as View );
+
+		expect( query.min_stock_quantity ).toEqual( '6' );
+		expect( query.max_stock_quantity ).toBeUndefined();
+	} );
+
+	it( 'maps the stock_quantity greaterThanOrEqual filter to min_stock_quantity', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'stock_quantity',
+					operator: 'greaterThanOrEqual',
+					value: 5,
+				},
+			],
+		} as View );
+
+		expect( query.min_stock_quantity ).toEqual( '5' );
+		expect( query.max_stock_quantity ).toBeUndefined();
+	} );
+
+	it( 'maps the stock_quantity lessThan filter to max_stock_quantity - 1', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{ field: 'stock_quantity', operator: 'lessThan', value: 20 },
+			],
+		} as View );
+
+		expect( query.max_stock_quantity ).toEqual( '19' );
+		expect( query.min_stock_quantity ).toBeUndefined();
+	} );
+
+	it( 'maps the stock_quantity lessThanOrEqual filter to max_stock_quantity', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'stock_quantity',
+					operator: 'lessThanOrEqual',
+					value: 20,
+				},
+			],
+		} as View );
+
+		expect( query.max_stock_quantity ).toEqual( '20' );
+		expect( query.min_stock_quantity ).toBeUndefined();
+	} );
+
+	it( 'maps the stock_quantity between filter to both min and max', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'stock_quantity',
+					operator: 'between',
+					value: [ 5, 20 ],
+				},
+			],
+		} as View );
+
+		expect( query.min_stock_quantity ).toEqual( '5' );
+		expect( query.max_stock_quantity ).toEqual( '20' );
+	} );
+
+	it( 'leaves stock_quantity bounds unset for the isNot operator (no server-side support)', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'stock_quantity',
+					operator: 'isNot',
+					value: 5,
+				},
+			],
+		} as View );
+
+		expect( query.min_stock_quantity ).toBeUndefined();
+		expect( query.max_stock_quantity ).toBeUndefined();
+	} );
 } );
diff --git a/packages/js/experimental-products-app/src/product-list/query.ts b/packages/js/experimental-products-app/src/product-list/query.ts
index 274637a0cb4..8ba7308e5fa 100644
--- a/packages/js/experimental-products-app/src/product-list/query.ts
+++ b/packages/js/experimental-products-app/src/product-list/query.ts
@@ -190,6 +190,52 @@ function applyPriceFilter( query: ProductListQuery, filter: Filter ) {
 	query.max_price = price;
 }

+function applyStockQuantityFilter( query: ProductListQuery, filter: Filter ) {
+	if ( filter.operator === 'between' && Array.isArray( filter.value ) ) {
+		const [ min, max ] = filter.value;
+		query.min_stock_quantity = getPriceValue( min );
+		query.max_stock_quantity = getPriceValue( max );
+		return;
+	}
+
+	if ( filter.operator === 'isNot' ) {
+		// No WC REST param for stock_quantity exclusion; intentionally
+		// unsupported server-side. The operator is exposed because the user
+		// explicitly asked for it in the UI.
+		return;
+	}
+
+	const raw = getPriceValue( filter.value );
+
+	if ( ! raw ) {
+		return;
+	}
+
+	const numeric = Number( raw );
+
+	if ( ! Number.isFinite( numeric ) ) {
+		return;
+	}
+
+	switch ( filter.operator ) {
+		case 'is':
+			query.min_stock_quantity = raw;
+			query.max_stock_quantity = raw;
+			return;
+		case 'greaterThan':
+			query.min_stock_quantity = String( numeric + 1 );
+			return;
+		case 'greaterThanOrEqual':
+			query.min_stock_quantity = raw;
+			return;
+		case 'lessThan':
+			query.max_stock_quantity = String( numeric - 1 );
+			return;
+		case 'lessThanOrEqual':
+			query.max_stock_quantity = raw;
+	}
+}
+
 export function buildProductListQuery( view: View ): ProductListQuery {
 	const query: ProductListQuery = {
 		_embed: 1,
@@ -231,6 +277,9 @@ export function buildProductListQuery( view: View ): ProductListQuery {
 			case 'price':
 				applyPriceFilter( query, filter );
 				break;
+			case 'stock_quantity':
+				applyStockQuantityFilter( query, filter );
+				break;
 		}
 	} );