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"
]
}