Commit 349bc77193b for woocommerce

commit 349bc77193bb0512382d66d8c02847ac953f349c
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue Apr 28 10:07:18 2026 +0200

    [DataViews - All Products] Implement filters (#64424)

    * Refactor product actions and improve layout handling in the experimental products app

    * lint code

    * Update packages/js/experimental-products-app/typings/index.d.ts

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

    * fix hook

    * lint code

    * Implement filters

    * Add changefile(s) from automation for the following project(s): @woocommerce/experimental-products-app

    * clean up code

    * fix logic

    * fix unit test

    * improve test

    ---------

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/packages/js/experimental-products-app/changelog/64424-add-filters b/packages/js/experimental-products-app/changelog/64424-add-filters
new file mode 100644
index 00000000000..ceb2dccdbc7
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/64424-add-filters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add product list filters to the experimental products app
\ No newline at end of file
diff --git a/packages/js/experimental-products-app/src/fields/type/field.tsx b/packages/js/experimental-products-app/src/fields/type/field.tsx
new file mode 100644
index 00000000000..8bca3b39d26
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/type/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';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Product type', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: {
+		operators: [ 'isAny', 'isNone' ],
+	},
+	elements: [
+		{ label: __( 'Simple', 'woocommerce' ), value: 'simple' },
+		{ label: __( 'Variable', 'woocommerce' ), value: 'variable' },
+		{ label: __( 'Grouped', 'woocommerce' ), value: 'grouped' },
+		{ label: __( 'External', 'woocommerce' ), value: 'external' },
+	],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => item.type,
+};
diff --git a/packages/js/experimental-products-app/src/product-list/fields.tsx b/packages/js/experimental-products-app/src/product-list/fields.tsx
index dd48aab940a..638116a1fbd 100644
--- a/packages/js/experimental-products-app/src/product-list/fields.tsx
+++ b/packages/js/experimental-products-app/src/product-list/fields.tsx
@@ -42,6 +42,7 @@ import { fieldExtensions as stockQuantityFieldExtensions } from '../fields/stock
 import { fieldExtensions as tagsFieldExtensions } from '../fields/tags/field';
 import { fieldExtensions as taxStatusFieldExtensions } from '../fields/tax_status/field';
 import type { ProductEntityRecord } from '../fields/types';
+import { fieldExtensions as typeFieldExtensions } from '../fields/type/field';
 import { fieldExtensions as upsellIdsFieldExtensions } from '../fields/upsell_ids/field';
 import { fieldExtensions as visibilitySummaryFieldExtensions } from '../fields/visibility_summary/field';
 import { fieldExtensions as weightFieldExtensions } from '../fields/weight/field';
@@ -73,6 +74,7 @@ const PRODUCT_LIST_FIELD_IDS = [
 	'categories',
 	'tags',
 	'organization_summary',
+	'type',
 	'featured',
 	'catalog_visibility',
 	'visibility_summary',
@@ -119,6 +121,7 @@ const PRODUCT_LIST_FIELD_EXTENSIONS: Record< string, ProductFieldExtensions > =
 		categories: categoriesFieldExtensions,
 		tags: tagsFieldExtensions,
 		organization_summary: organizationSummaryFieldExtensions,
+		type: typeFieldExtensions,
 		featured: featuredFieldExtensions,
 		catalog_visibility: catalogVisibilityFieldExtensions,
 		visibility_summary: visibilitySummaryFieldExtensions,
diff --git a/packages/js/experimental-products-app/src/product-list/index.tsx b/packages/js/experimental-products-app/src/product-list/index.tsx
index ee6e2690944..3f009701564 100644
--- a/packages/js/experimental-products-app/src/product-list/index.tsx
+++ b/packages/js/experimental-products-app/src/product-list/index.tsx
@@ -3,7 +3,6 @@
  */
 import { DataViews, View } from '@wordpress/dataviews';
 import { useState, useMemo, useCallback, useEffect } from '@wordpress/element';
-import { ProductQuery } from '@woocommerce/data';
 import { privateApis as routerPrivateApis } from '@wordpress/router';
 import { store as coreStore, useEntityRecords } from '@wordpress/core-data';
 import { __ } from '@wordpress/i18n';
@@ -25,6 +24,7 @@ import {
 	DEFAULT_PRODUCT_TABLE_LAYOUT,
 	DEFAULT_PRODUCT_TABLE_VIEW,
 } from './layouts';
+import { buildProductListQuery } from './query';
 import { useProductActions } from '../dataviews-actions';

 const { usePostActions } = unlock( editorPrivateApis );
@@ -87,32 +87,10 @@ export default function ProductList( { className }: ProductListProps ) {
 	const [ selection, setSelection ] = useState( [ postId ] );
 	const [ view, setView ] = useView( postType );

-	const queryParams = useMemo( () => {
-		const filters: Partial< ProductQuery > = {};
-		view.filters?.forEach( ( filter ) => {
-			if (
-				filter.field === 'status' ||
-				filter.field === 'product_status'
-			) {
-				filters.status = Array.isArray( filter.value )
-					? filter.value.join( ',' )
-					: filter.value;
-			}
-		} );
-		const orderby =
-			view.sort?.field === 'name'
-				? 'title'
-				: ( view.sort?.field as ProductQuery[ 'orderby' ] );
-
-		return {
-			per_page: view.perPage,
-			page: view.page,
-			order: view.sort?.direction,
-			orderby,
-			search: view.search,
-			...filters,
-		};
-	}, [ view ] );
+	const queryParams = useMemo(
+		() => buildProductListQuery( view ),
+		[ view ]
+	);

 	const onChangeSelection = useCallback(
 		( items: string[] ) => {
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
new file mode 100644
index 00000000000..190ecf03aa8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-list/query.test.ts
@@ -0,0 +1,154 @@
+/**
+ * External dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { buildProductListQuery } from './query';
+
+describe( 'buildProductListQuery', () => {
+	const baseView = {
+		type: 'table',
+		page: 3,
+		perPage: 25,
+		search: 'hoodie',
+		sort: {
+			field: 'name',
+			direction: 'asc',
+		},
+		filters: [],
+	} as View;
+
+	it( 'maps the base view query params', () => {
+		expect( buildProductListQuery( baseView ) ).toEqual(
+			expect.objectContaining( {
+				per_page: 25,
+				page: 3,
+				order: 'asc',
+				orderby: 'title',
+				search_name_or_sku: 'hoodie',
+			} )
+		);
+	} );
+
+	it( 'maps supported filters to the v4 product query', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'type',
+					operator: 'isAny',
+					value: [ 'simple', 'variable' ],
+				},
+				{
+					field: 'categories',
+					operator: 'isAny',
+					value: [ '12', 13 ],
+				},
+				{
+					field: 'stock',
+					operator: 'is',
+					value: 'outofstock',
+				},
+				{
+					field: 'price',
+					operator: 'between',
+					value: [ 10, 25 ],
+				},
+			],
+		} as View );
+
+		expect( query ).toEqual(
+			expect.objectContaining( {
+				include_types: [ 'simple', 'variable' ],
+				category: '12,13',
+				stock_status: 'outofstock',
+				min_price: '10',
+				max_price: '25',
+			} )
+		);
+	} );
+
+	it( 'maps exclusion filters for supported types and categories', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'type',
+					operator: 'isNone',
+					value: [ 'grouped' ],
+				},
+				{
+					field: 'categories',
+					operator: 'isNone',
+					value: [ '9', 11 ],
+				},
+			],
+		} as View );
+
+		expect( query.exclude_types ).toEqual( [ 'grouped' ] );
+		expect( query.exclude_category ).toEqual( [ 9, 11 ] );
+	} );
+
+	it( 'maps an exact price filter to both min and max price', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'price',
+					operator: 'is',
+					value: '15',
+				},
+			],
+		} as View );
+
+		expect( query.min_price ).toBe( '15' );
+		expect( query.max_price ).toBe( '15' );
+	} );
+
+	it( 'maps one-sided price filters', () => {
+		const minimumOnlyQuery = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'price',
+					operator: 'greaterThanOrEqual',
+					value: '15',
+				},
+			],
+		} as View );
+
+		const maximumOnlyQuery = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'price',
+					operator: 'lessThanOrEqual',
+					value: 25,
+				},
+			],
+		} as View );
+
+		expect( minimumOnlyQuery.min_price ).toBe( '15' );
+		expect( minimumOnlyQuery.max_price ).toBeUndefined();
+		expect( maximumOnlyQuery.min_price ).toBeUndefined();
+		expect( maximumOnlyQuery.max_price ).toBe( '25' );
+	} );
+
+	it( 'maps stock filters from a selected stock status', () => {
+		const query = buildProductListQuery( {
+			...baseView,
+			filters: [
+				{
+					field: 'stock',
+					operator: 'is',
+					value: 'onbackorder',
+				},
+			],
+		} as View );
+
+		expect( query.stock_status ).toBe( 'onbackorder' );
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/product-list/query.ts b/packages/js/experimental-products-app/src/product-list/query.ts
new file mode 100644
index 00000000000..bbb09cc5746
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-list/query.ts
@@ -0,0 +1,184 @@
+/**
+ * External dependencies
+ */
+import type { Filter, View } from '@wordpress/dataviews';
+import type {
+	ProductQuery,
+	ProductStatus,
+	ProductType,
+} from '@woocommerce/data';
+
+export type ProductListQuery = ProductQuery & {
+	search_name_or_sku?: string;
+	include_status?: ProductStatus[];
+	exclude_status?: ProductStatus[];
+	include_types?: ProductType[];
+	exclude_types?: ProductType[];
+	exclude_category?: number[];
+	min_stock_quantity?: string;
+	max_stock_quantity?: string;
+};
+
+const SUPPORTED_STATUS_FILTER_FIELDS = [ 'status', 'product_status' ];
+
+function isStringArray( value: unknown ): value is string[] {
+	return (
+		Array.isArray( value ) &&
+		value.every( ( item ) => typeof item === 'string' )
+	);
+}
+
+function getStringValues( value: unknown ): string[] {
+	if ( isStringArray( value ) ) {
+		return value.filter( Boolean );
+	}
+
+	if ( typeof value === 'string' && value ) {
+		return [ value ];
+	}
+
+	return [];
+}
+
+function getNumericValues( value: unknown ): number[] {
+	const values = Array.isArray( value ) ? value : [ value ];
+	return values.map( ( item ) => {
+		if ( typeof item === 'number' ) {
+			return item;
+		}
+		if ( typeof item === 'string' ) {
+			return Number( item );
+		}
+		return Number.NaN;
+	} );
+}
+
+function getPriceValue( value: unknown ): string | undefined {
+	if ( typeof value === 'number' && Number.isFinite( value ) ) {
+		return String( value );
+	}
+
+	if ( typeof value === 'string' && value !== '' ) {
+		return value;
+	}
+
+	return undefined;
+}
+
+function applyStatusFilter( query: ProductListQuery, filter: Filter ) {
+	const values = getStringValues( filter.value ) as ProductStatus[];
+
+	if ( values.length === 0 ) {
+		return;
+	}
+
+	if ( filter.operator === 'isNot' || filter.operator === 'isNone' ) {
+		query.exclude_status = values;
+		return;
+	}
+
+	query.include_status = values;
+}
+
+function applyTypeFilter( query: ProductListQuery, filter: Filter ) {
+	const values = getStringValues( filter.value ) as ProductType[];
+
+	if ( values.length === 0 ) {
+		return;
+	}
+
+	if ( filter.operator === 'isNot' || filter.operator === 'isNone' ) {
+		query.exclude_types = values;
+		return;
+	}
+
+	query.include_types = values;
+}
+
+function applyCategoryFilter( query: ProductListQuery, filter: Filter ) {
+	const values = getNumericValues( filter.value );
+
+	if ( values.length === 0 ) {
+		return;
+	}
+
+	if ( filter.operator === 'isNone' ) {
+		query.exclude_category = values;
+		return;
+	}
+
+	query.category = values.join( ',' );
+}
+
+function applyStockFilter( query: ProductListQuery, filter: Filter ) {
+	const [ stockStatus ] = getStringValues( filter.value );
+
+	if ( stockStatus ) {
+		query.stock_status = stockStatus as ProductListQuery[ 'stock_status' ];
+	}
+}
+
+function applyPriceFilter( query: ProductListQuery, filter: Filter ) {
+	if ( filter.operator === 'between' && Array.isArray( filter.value ) ) {
+		const [ minPrice, maxPrice ] = filter.value;
+		query.min_price = getPriceValue( minPrice );
+		query.max_price = getPriceValue( maxPrice );
+		return;
+	}
+
+	const price = getPriceValue( filter.value );
+
+	if ( ! price ) {
+		return;
+	}
+
+	if ( filter.operator === 'greaterThanOrEqual' ) {
+		query.min_price = price;
+		return;
+	}
+
+	if ( filter.operator === 'lessThanOrEqual' ) {
+		query.max_price = price;
+		return;
+	}
+
+	query.min_price = price;
+	query.max_price = price;
+}
+
+export function buildProductListQuery( view: View ): ProductListQuery {
+	const query: ProductListQuery = {
+		per_page: view.perPage,
+		page: view.page,
+		order: view.sort?.direction,
+		orderby:
+			view.sort?.field === 'name'
+				? 'title'
+				: ( view.sort?.field as ProductQuery[ 'orderby' ] ),
+		search_name_or_sku: view.search || undefined,
+	};
+
+	view.filters?.forEach( ( filter ) => {
+		if ( SUPPORTED_STATUS_FILTER_FIELDS.includes( filter.field ) ) {
+			applyStatusFilter( query, filter );
+			return;
+		}
+
+		switch ( filter.field ) {
+			case 'type':
+				applyTypeFilter( query, filter );
+				break;
+			case 'categories':
+				applyCategoryFilter( query, filter );
+				break;
+			case 'stock':
+				applyStockFilter( query, filter );
+				break;
+			case 'price':
+				applyPriceFilter( query, filter );
+				break;
+		}
+	} );
+
+	return query;
+}
diff --git a/packages/js/experimental-products-app/tsconfig-cjs.json b/packages/js/experimental-products-app/tsconfig-cjs.json
index 99f1eb73db6..da0d647649a 100644
--- a/packages/js/experimental-products-app/tsconfig-cjs.json
+++ b/packages/js/experimental-products-app/tsconfig-cjs.json
@@ -16,6 +16,8 @@
 	],
 	"exclude": [
 		"**/test/**",
-		"**/stories/**"
+		"**/stories/**",
+		"**/*.test.ts",
+		"**/*.test.tsx"
 	]
 }