Commit 73a9cc3e94d for woocommerce
commit 73a9cc3e94dfb8454bc4f6bfefcc99c0849b1c74
Author: Luigi Teschio <gigitux@gmail.com>
Date: Wed May 20 10:56:58 2026 +0200
Allow multiple stock status filters (#65155)
* Allow multiple stock status filters
* address comment
diff --git a/packages/js/data/changelog/fix-rsm-3550-stock-status-query-type b/packages/js/data/changelog/fix-rsm-3550-stock-status-query-type
new file mode 100644
index 00000000000..796d41aa8c6
--- /dev/null
+++ b/packages/js/data/changelog/fix-rsm-3550-stock-status-query-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow product queries to type multiple stock statuses.
diff --git a/packages/js/data/src/products/types.ts b/packages/js/data/src/products/types.ts
index fe956640bce..79c9aef73fa 100644
--- a/packages/js/data/src/products/types.ts
+++ b/packages/js/data/src/products/types.ts
@@ -206,7 +206,11 @@ export type ProductQuery<
on_sale?: boolean;
min_price?: string;
max_price?: string;
- stock_status?: 'instock' | 'outofstock' | 'onbackorder';
+ stock_status?:
+ | 'instock'
+ | 'outofstock'
+ | 'onbackorder'
+ | Array< 'instock' | 'outofstock' | 'onbackorder' >;
};
export type SuggestedProductOptionsKey = string;
diff --git a/packages/js/experimental-products-app/changelog/fix-rsm-3550-stock-multiselect b/packages/js/experimental-products-app/changelog/fix-rsm-3550-stock-multiselect
new file mode 100644
index 00000000000..de24640f9c7
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-rsm-3550-stock-multiselect
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow selecting multiple stock statuses in product list filters.
diff --git a/packages/js/experimental-products-app/src/fields/stock/field.tsx b/packages/js/experimental-products-app/src/fields/stock/field.tsx
index 140dc67c369..4fb79a76cbd 100644
--- a/packages/js/experimental-products-app/src/fields/stock/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/stock/field.tsx
@@ -32,7 +32,7 @@ const fieldDefinition = {
enableSorting: false,
enableHiding: false,
filterBy: {
- operators: [ 'is' ],
+ operators: [ 'isAny' ],
},
elements: [
{ label: __( 'In stock', 'woocommerce' ), value: 'instock' },
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 8262128b32e..6b32d587e5b 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
@@ -159,6 +159,36 @@ describe( 'buildProductListQuery', () => {
expect( query.stock_status ).toBe( 'onbackorder' );
} );
+ it( 'maps stock filters from multiple selected stock statuses', () => {
+ const query = buildProductListQuery( {
+ ...baseView,
+ filters: [
+ {
+ field: 'stock',
+ operator: 'isAny',
+ value: [ 'instock', 'onbackorder' ],
+ },
+ ],
+ } as View );
+
+ expect( query.stock_status ).toEqual( [ 'instock', 'onbackorder' ] );
+ } );
+
+ it( 'ignores empty stock filters until a value is selected', () => {
+ const query = buildProductListQuery( {
+ ...baseView,
+ filters: [
+ {
+ field: 'stock',
+ operator: 'isAny',
+ value: [],
+ },
+ ],
+ } as View );
+
+ expect( query.stock_status ).toBeUndefined();
+ } );
+
it( 'ignores empty taxonomy filters until a value is selected', () => {
const query = buildProductListQuery( {
...baseView,
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 82d5514e42a..a5eff74171a 100644
--- a/packages/js/experimental-products-app/src/product-list/query.ts
+++ b/packages/js/experimental-products-app/src/product-list/query.ts
@@ -8,8 +8,14 @@ import type {
ProductType,
} from '@woocommerce/data';
-export type ProductListQuery = Omit< ProductQuery, 'status' > & {
+type ProductStockStatus = 'instock' | 'outofstock' | 'onbackorder';
+
+export type ProductListQuery = Omit<
+ ProductQuery,
+ 'status' | 'stock_status'
+> & {
status?: ProductStatus | ProductStatus[];
+ stock_status?: ProductStockStatus | ProductStockStatus[];
_embed?: number;
search_name_or_sku?: string;
exclude_status?: ProductStatus[];
@@ -141,10 +147,13 @@ function applyBrandFilter( query: ProductListQuery, filter: Filter ) {
}
function applyStockFilter( query: ProductListQuery, filter: Filter ) {
- const [ stockStatus ] = getStringValues( filter.value );
+ const stockStatuses = getStringValues(
+ filter.value
+ ) as ProductStockStatus[];
- if ( stockStatus ) {
- query.stock_status = stockStatus as ProductListQuery[ 'stock_status' ];
+ if ( stockStatuses.length > 0 ) {
+ query.stock_status =
+ stockStatuses.length === 1 ? stockStatuses[ 0 ] : stockStatuses;
}
}
diff --git a/plugins/woocommerce/changelog/fix-rsm-3550-stock-status-filter b/plugins/woocommerce/changelog/fix-rsm-3550-stock-status-filter
new file mode 100644
index 00000000000..725c21dadde
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-rsm-3550-stock-status-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow the v4 products endpoint to filter by multiple stock statuses.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index 7507a4dea7e..93d182ce5f7 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -535,8 +535,9 @@ class Controller extends WC_REST_Products_V2_Controller {
$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$args,
array(
- 'key' => '_stock_status',
- 'value' => $request['stock_status'],
+ 'key' => '_stock_status',
+ 'value' => $request['stock_status'],
+ 'compare' => 'IN',
)
);
}
@@ -2083,10 +2084,13 @@ class Controller extends WC_REST_Products_V2_Controller {
unset( $params['in_stock'] );
$params['stock_status'] = array(
- 'description' => __( 'Limit result set to products with specified stock status.', 'woocommerce' ),
- 'type' => 'string',
- 'enum' => array_keys( wc_get_product_stock_status_options() ),
- 'sanitize_callback' => 'sanitize_text_field',
+ 'description' => __( 'Limit result set to products with any of the specified stock statuses.', 'woocommerce' ),
+ 'type' => array( 'string', 'array' ),
+ 'items' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_stock_status_options() ),
+ ),
+ 'sanitize_callback' => 'wp_parse_list',
'validate_callback' => 'rest_validate_request_arg',
);
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
index 66fb375bcf9..1227a73ab4d 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
@@ -24,6 +24,14 @@ As discussed in the team conversation:
## Change Log
+### 2026-05-19 - Support multiple stock status filters
+
+**Summary**: Updated the collection `stock_status` query parameter to accept an array of stock statuses, enabling clients to request products matching any selected stock status. The endpoint now validates each value against WooCommerce stock status options, sanitizes the parameter with `wp_parse_list`, and applies the filter with an `_stock_status IN (...)` meta query. Single stock status values remain supported through list parsing.
+
+**PR**: [65155](https://github.com/woocommerce/woocommerce/pull/65155)
+
+**Breaking Changes**: None
+
### 2026-05-05 - Add embedded variation links
**Summary**: Added embeddable `variations` links to variable product responses so child variations can be embedded when requesting products with `_embed=1`. The product schema now exposes the `embed` context for fields that are already available in `view` context, while sensitive fields such as downloads, metadata, purchase notes, and cost of goods sold remain excluded from embedded variation responses.
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index 9fa4a9c6a62..bd3a074b25f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Products;
use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Products\Controller as ProductsController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
@@ -1189,6 +1190,108 @@ class ProductsControllerTest extends WC_REST_Unit_Test_Case {
$this->assertEqualsCanonicalizing( array( ProductType::EXTERNAL, ProductType::GROUPED, ProductType::GROUPED ), $product_types );
}
+ /**
+ * @testdox Should accept scalar and array stock status collection filters.
+ *
+ * @dataProvider stock_status_filter_query_provider
+ *
+ * @param string|array $stock_status_query Query value for the stock_status parameter.
+ * @param array $expected_stock_statuses Expected normalized stock status values.
+ */
+ public function test_collection_filter_with_stock_statuses( $stock_status_query, array $expected_stock_statuses ): void {
+ $normalized_stock_status = null;
+ $capture_stock_status = static function ( $response, $handler, $request ) use ( &$normalized_stock_status ) {
+ if ( '/wc/v4/products' === $request->get_route() ) {
+ $normalized_stock_status = $request->get_param( 'stock_status' );
+ }
+
+ return $response;
+ };
+
+ $in_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Stock Filter Target In Stock',
+ 'sku' => 'stock-filter-target-in-stock',
+ )
+ );
+ $out_of_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Stock Filter Target Out Of Stock',
+ 'sku' => 'stock-filter-target-out-of-stock',
+ )
+ );
+ $backorder_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Stock Filter Target Backorder',
+ 'sku' => 'stock-filter-target-backorder',
+ )
+ );
+
+ $in_stock_product->set_stock_status( ProductStockStatus::IN_STOCK );
+ $in_stock_product->save();
+ $out_of_stock_product->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
+ $out_of_stock_product->save();
+ $backorder_product->set_stock_status( ProductStockStatus::ON_BACKORDER );
+ $backorder_product->save();
+
+ try {
+ add_filter( 'rest_request_before_callbacks', $capture_stock_status, 10, 3 );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'search_name_or_sku' => 'stock-filter-target',
+ 'stock_status' => $stock_status_query,
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( $expected_stock_statuses, $normalized_stock_status, 'Stock status should be normalized to a list.' );
+
+ $product_ids = wp_list_pluck( $response->get_data(), 'id' );
+ $products = array(
+ ProductStockStatus::IN_STOCK => $in_stock_product,
+ ProductStockStatus::OUT_OF_STOCK => $out_of_stock_product,
+ ProductStockStatus::ON_BACKORDER => $backorder_product,
+ );
+
+ foreach ( $products as $stock_status => $product ) {
+ if ( in_array( $stock_status, $expected_stock_statuses, true ) ) {
+ $this->assertContains( $product->get_id(), $product_ids );
+ } else {
+ $this->assertNotContains( $product->get_id(), $product_ids );
+ }
+ }
+ } finally {
+ remove_filter( 'rest_request_before_callbacks', $capture_stock_status, 10 );
+ WC_Helper_Product::delete_product( $in_stock_product->get_id() );
+ WC_Helper_Product::delete_product( $out_of_stock_product->get_id() );
+ WC_Helper_Product::delete_product( $backorder_product->get_id() );
+ }
+ }
+
+ /**
+ * Data provider for stock status collection filters.
+ *
+ * @return array
+ */
+ public function stock_status_filter_query_provider() {
+ return array(
+ 'scalar stock status' => array(
+ ProductStockStatus::IN_STOCK,
+ array( ProductStockStatus::IN_STOCK ),
+ ),
+ 'array stock statuses' => array(
+ array( ProductStockStatus::OUT_OF_STOCK, ProductStockStatus::ON_BACKORDER ),
+ array( ProductStockStatus::OUT_OF_STOCK, ProductStockStatus::ON_BACKORDER ),
+ ),
+ );
+ }
+
/**
* Test that the `include_types` parameter handles invalid status values.
*/