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;
}
} );