Commit 5de94b83a64 for woocommerce

commit 5de94b83a6486503d7c59c1548cacbc06c3bea0b
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Wed Apr 29 10:20:07 2026 +0200

    [DataViews - All Products] Implement tab navigation  (#64449)

    * 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

    * [DataViews - All Products] Implement bulk editing

    * clean up code

    * fix logic

    * fix unit test

    * improve test

    * update configuration

    * implement status tab

    * fix import

    * add link to the title

    * clean up code

    * lint code

    * fix code

    * address commit

    * fix build

    * improve code quality

    * update logic

    ---------

    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/src/dataviews-actions/index.test.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
similarity index 90%
rename from packages/js/experimental-products-app/src/dataviews-actions/index.test.tsx
rename to packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
index da6cfaf2697..55d861ea405 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/index.test.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
@@ -8,9 +8,8 @@ import type { Action } from '@wordpress/dataviews';
 /**
  * Internal dependencies
  */
-import { duplicateProductAction, moveToTrashAction } from './index';
+import { duplicateProductAction, moveToTrashAction } from './actions';
 import type { ProductEntityRecord } from '../fields/types';
-import type { ProductListQuery } from '../product-list/query';

 jest.mock( '@wordpress/api-fetch', () => jest.fn() );

@@ -59,10 +58,6 @@ function getCallbackAction( action: Action< ProductEntityRecord > ) {
 }

 describe( 'product list actions', () => {
-	const query: ProductListQuery = {
-		page: 1,
-		per_page: 20,
-	};
 	const product = {
 		id: 12,
 		status: 'draft',
@@ -158,12 +153,9 @@ describe( 'product list actions', () => {
 	it( 'moves products to trash through coreStore root/product and refreshes the query', async () => {
 		deleteEntityRecord.mockResolvedValue( { id: 12 } );

-		await getCallbackAction( moveToTrashAction( { query } ) ).callback(
-			[ product ],
-			{
-				onActionPerformed,
-			}
-		);
+		await getCallbackAction( moveToTrashAction() ).callback( [ product ], {
+			onActionPerformed,
+		} );

 		expect( deleteEntityRecord ).toHaveBeenCalledWith(
 			'root',
@@ -174,10 +166,7 @@ describe( 'product list actions', () => {
 				throwOnError: true,
 			}
 		);
-		expect( invalidateResolution ).toHaveBeenCalledWith(
-			'getEntityRecords',
-			[ 'root', 'product', query ]
-		);
+
 		expect( createSuccessNotice ).toHaveBeenCalledWith(
 			'Product successfully deleted',
 			{ type: 'snackbar' }
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/index.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
similarity index 92%
rename from packages/js/experimental-products-app/src/dataviews-actions/index.tsx
rename to packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
index a0547a399e4..5386755f552 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/index.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
@@ -16,15 +16,6 @@ import { useMemo } from '@wordpress/element';
  * Internal dependencies
  */
 import type { ProductEntityRecord } from '../fields/types';
-import type { ProductListQuery } from '../product-list/query';
-
-type ProductActionsOptions = {
-	query: ProductListQuery;
-};
-
-type ProductActionDependencies = {
-	query: ProductListQuery;
-};

 function getErrorMessage( error: unknown ): string {
 	if ( error instanceof Error ) {
@@ -259,9 +250,7 @@ export const duplicateProductAction = (): Action< ProductEntityRecord > => ( {
 	},
 } );

-export const moveToTrashAction = (
-	dependencies: ProductActionDependencies
-): Action< ProductEntityRecord > => ( {
+export const moveToTrashAction = (): Action< ProductEntityRecord > => ( {
 	id: 'move-to-trash-product',
 	label: __( 'Move to trash', 'woocommerce' ),
 	supportsBulk: true,
@@ -270,8 +259,7 @@ export const moveToTrashAction = (
 		return product.status !== 'trash';
 	},
 	async callback( items, { onActionPerformed } ) {
-		const { deleteEntityRecord, invalidateResolution } =
-			dispatch( coreStore );
+		const { deleteEntityRecord } = dispatch( coreStore );
 		const { createErrorNotice, createSuccessNotice } =
 			dispatch( noticesStore );

@@ -289,11 +277,6 @@ export const moveToTrashAction = (
 		);

 		if ( successfulItems.length > 0 ) {
-			await invalidateResolution( 'getEntityRecords', [
-				'root',
-				'product',
-				dependencies.query,
-			] );
 			createSuccessNotice(
 				successfulItems.length === 1
 					? __( 'Product successfully deleted', 'woocommerce' )
@@ -327,14 +310,14 @@ export const moveToTrashAction = (
 	},
 } );

-export const useProductActions = ( { query }: ProductActionsOptions ) => {
+export const useProductActions = () => {
 	return useMemo(
 		() => [
 			editAction(),
 			viewAction(),
 			duplicateProductAction(),
-			moveToTrashAction( { query } ),
+			moveToTrashAction(),
 		],
-		[ query ]
+		[]
 	);
 };
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/index.ts b/packages/js/experimental-products-app/src/dataviews-actions/index.ts
new file mode 100644
index 00000000000..4b969aa27b9
--- /dev/null
+++ b/packages/js/experimental-products-app/src/dataviews-actions/index.ts
@@ -0,0 +1 @@
+export { useProductActions } from './actions';
diff --git a/packages/js/experimental-products-app/src/product-list/constants.ts b/packages/js/experimental-products-app/src/product-list/constants.ts
new file mode 100644
index 00000000000..0240ed80c1a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-list/constants.ts
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import {
+	DEFAULT_PRODUCT_TABLE_LAYOUT,
+	DEFAULT_PRODUCT_TABLE_VIEW,
+} from './layouts';
+
+export const PAGE_SIZE = 20;
+
+export const EMPTY_ARRAY: ProductEntityRecord[] = [];
+
+export const DEFAULT_LAYOUTS = {
+	table: DEFAULT_PRODUCT_TABLE_LAYOUT,
+};
+
+export const DEFAULT_VIEW: View = {
+	...DEFAULT_PRODUCT_TABLE_VIEW,
+	page: 1,
+};
+
+export const PRODUCT_LIST_TAB_VALUES = [
+	'all',
+	'publish',
+	'draft',
+	'trash',
+] as const;
+
+export type StatusTab = ( typeof PRODUCT_LIST_TAB_VALUES )[ number ];
+
+export const PRODUCT_LIST_TABS: Array< {
+	value: StatusTab;
+	label: string;
+} > = [
+	{
+		value: 'all',
+		label: __( 'All', 'woocommerce' ),
+	},
+	{
+		value: 'publish',
+		label: __( 'Published', 'woocommerce' ),
+	},
+	{
+		value: 'draft',
+		label: __( 'Draft', 'woocommerce' ),
+	},
+	{
+		value: 'trash',
+		label: __( 'Trash', 'woocommerce' ),
+	},
+];
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 638116a1fbd..7cd45203ff1 100644
--- a/packages/js/experimental-products-app/src/product-list/fields.tsx
+++ b/packages/js/experimental-products-app/src/product-list/fields.tsx
@@ -92,9 +92,6 @@ const PRODUCT_LIST_FIELD_IDS = [
 	'upsell_ids',
 	'cross_sell_ids',
 	'linked_products_count',
-	'seo_title',
-	'seo_description',
-	'seo_preview',
 ] as const;

 const PRODUCT_LIST_FIELD_EXTENSIONS: Record< string, ProductFieldExtensions > =
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 b2d179065e6..84c08ed6fe4 100644
--- a/packages/js/experimental-products-app/src/product-list/index.tsx
+++ b/packages/js/experimental-products-app/src/product-list/index.tsx
@@ -4,27 +4,38 @@
 import { DataViews, View } from '@wordpress/dataviews';
 import { useState, useMemo, useCallback, useEffect } from '@wordpress/element';
 import { privateApis as routerPrivateApis } from '@wordpress/router';
-import { store as coreStore, useEntityRecords } from '@wordpress/core-data';
-import { __ } from '@wordpress/i18n';
+import { store as coreStore } from '@wordpress/core-data';
 import { useSelect } from '@wordpress/data';
 import clsx from 'clsx';
-import { Button, Stack } from '@wordpress/ui';
+import { Button, Stack, Tabs } from '@wordpress/ui';
 import { privateApis as editorPrivateApis } from '@wordpress/editor';
 import { Page } from '@wordpress/admin-ui';
 import { addQueryArgs } from '@wordpress/url';
 import { getAdminLink } from '@woocommerce/settings';
+import { __ } from '@wordpress/i18n';

 /**
  * Internal dependencies
  */
 import { unlock } from '../lock-unlock';
 import type { ProductEntityRecord } from '../fields/types';
-import { productFields } from './fields';
 import {
-	DEFAULT_PRODUCT_TABLE_LAYOUT,
-	DEFAULT_PRODUCT_TABLE_VIEW,
-} from './layouts';
+	DEFAULT_LAYOUTS,
+	DEFAULT_VIEW,
+	EMPTY_ARRAY,
+	PAGE_SIZE,
+	PRODUCT_LIST_TABS,
+} from './constants';
+import { productFields } from './fields';
 import { buildProductListQuery } from './query';
+import {
+	getItemId,
+	getProductListNavigationPath,
+	getProductListTab,
+	getSelectionFromPostId,
+	getStatusForProductListTab,
+	isProductEditorAccessible,
+} from './utils';
 import { useProductActions } from '../dataviews-actions';

 const { usePostActions } = unlock( editorPrivateApis );
@@ -37,16 +48,6 @@ export type ProductListProps = {
 	postType?: string;
 };

-const PAGE_SIZE = 20;
-const EMPTY_ARRAY: ProductEntityRecord[] = [];
-const DEFAULT_LAYOUTS = {
-	table: DEFAULT_PRODUCT_TABLE_LAYOUT,
-};
-const DEFAULT_VIEW: View = {
-	...DEFAULT_PRODUCT_TABLE_VIEW,
-	page: 1,
-};
-
 /**
  * This function abstracts working with default & custom views by
  * providing a [ state, setState ] tuple based on the URL parameters.
@@ -54,63 +55,142 @@ const DEFAULT_VIEW: View = {
  * Consumers use the provided tuple to work with state
  * and don't have to deal with the specifics of default & custom views.
  *
- * @param {string} postType Post type to retrieve default views for.
  * @return {Array} The [ state, setState ] tuple.
  */
-function useView( postType: string ): [ View, ( view: View ) => void ] {
-	const {
-		params: { activeView = 'all', isCustom = 'false' },
-	} = useLocation();
+function useView(): [ View, ( view: View ) => void ] {
+	const { query: { activeView = 'all' } = {} } = useLocation();
 	const [ view, setView ] = useState< View >( DEFAULT_VIEW );

-	// When activeView or isCustom URL parameters change, reset the view.
+	// When activeView URL parameter changes, reset the view.
 	useEffect( () => {
 		setView( DEFAULT_VIEW );
-	}, [ activeView, isCustom, postType ] );
+	}, [ activeView ] );

 	return [ view, setView ];
 }

-function getItemId( item: ProductEntityRecord ) {
-	return item.id.toString();
-}
-
 export default function ProductList( { className }: ProductListProps ) {
-	const history = useHistory();
+	const { navigate } = useHistory();
 	const location = useLocation();
-	const {
-		postId,
-		postType = 'product',
-		isCustom,
-		activeView = 'all',
-	} = location.params;
-	const [ selection, setSelection ] = useState( [ postId ] );
-	const [ view, setView ] = useView( postType );
-
-	const queryParams = useMemo(
-		() => buildProductListQuery( view ),
-		[ view ]
+	const currentQuery = useMemo(
+		() =>
+			( location.query || {} ) as {
+				postId?: string;
+				activeView?: string;
+				postType?: string;
+			},
+		[ location.query ]
+	);
+	const { postId, postType = 'product', activeView = 'all' } = currentQuery;
+	const selectedTabFromLocation = getProductListTab( activeView );
+	const [ selectedTab, setSelectedTab ] = useState( selectedTabFromLocation );
+	const [ selection, setSelection ] = useState( () =>
+		getSelectionFromPostId( postId )
 	);
+	const [ view, setView ] = useView();
+
+	useEffect( () => {
+		setSelectedTab( selectedTabFromLocation );
+	}, [ selectedTabFromLocation ] );
+
+	useEffect( () => {
+		setSelection( getSelectionFromPostId( postId ) );
+	}, [ postId ] );
+
+	const queryParams = useMemo( () => {
+		const query = buildProductListQuery( view );
+		const productStatus = getStatusForProductListTab( selectedTab );
+
+		if ( productStatus ) {
+			query.status = productStatus;
+		}
+
+		return query;
+	}, [ selectedTab, view ] );

 	const onChangeSelection = useCallback(
 		( items: string[] ) => {
 			setSelection( items );
-			history.push( {
-				...location.params,
-				postId: items.join( ',' ),
-			} );
+
+			const nextParams = { ...currentQuery };
+
+			if ( items.length > 0 ) {
+				nextParams.postId = items.join( ',' );
+			} else {
+				delete nextParams.postId;
+			}
+
+			navigate(
+				getProductListNavigationPath( location.path, nextParams )
+			);
+		},
+		[ currentQuery, navigate, location.path ]
+	);
+
+	const onChangeTab = useCallback(
+		( value: string | null ) => {
+			if ( ! value ) {
+				return;
+			}
+
+			const nextTab = getProductListTab( value );
+
+			if ( nextTab === selectedTab ) {
+				return;
+			}
+
+			setSelectedTab( nextTab );
+			setSelection( [] );
+
+			const nextParams = {
+				...currentQuery,
+				activeView: nextTab,
+			};
+
+			delete nextParams.postId;
+
+			navigate(
+				getProductListNavigationPath( location.path, nextParams )
+			);
 		},
-		[ history, location.params ]
+		[ currentQuery, navigate, location.path, selectedTab ]
 	);

 	const {
 		records,
 		totalItems: totalCount,
 		isResolving: isLoading,
-	} = useEntityRecords< ProductEntityRecord >(
-		'root',
-		'product',
-		queryParams
+		hasResolved,
+	} = useSelect(
+		( select ) => {
+			const {
+				getEntityRecords,
+				isResolving,
+				hasFinishedResolution,
+				getEntityRecordsTotalItems,
+			} = select( coreStore );
+			return {
+				records: getEntityRecords< ProductEntityRecord >(
+					'root',
+					'product',
+					queryParams
+				),
+				totalItems: getEntityRecordsTotalItems( 'root', 'product', {
+					...queryParams,
+				} ),
+				isResolving: isResolving( 'getEntityRecords', [
+					'root',
+					'product',
+					queryParams,
+				] ),
+				hasResolved: hasFinishedResolution( 'getEntityRecords', [
+					'root',
+					'product',
+					queryParams,
+				] ),
+			};
+		},
+		[ queryParams ]
 	);

 	const paginationInfo = useMemo(
@@ -140,9 +220,7 @@ export default function ProductList( { className }: ProductListProps ) {
 		postType,
 		context: 'list',
 	} );
-	const productActions = useProductActions( {
-		query: queryParams,
-	} );
+	const productActions = useProductActions();
 	const actions = useMemo(
 		() => [
 			...productActions,
@@ -221,11 +299,11 @@ export default function ProductList( { className }: ProductListProps ) {
 			actions={ pageActions }
 		>
 			<DataViews
-				key={ activeView + isCustom }
+				key={ activeView }
 				paginationInfo={ paginationInfo }
 				fields={ productFields }
 				data={ records || EMPTY_ARRAY }
-				isLoading={ isLoading }
+				isLoading={ isLoading && ! hasResolved }
 				view={ view }
 				actions={ actions }
 				onChangeView={ setView }
@@ -233,7 +311,60 @@ export default function ProductList( { className }: ProductListProps ) {
 				getItemId={ getItemId }
 				selection={ selection }
 				defaultLayouts={ DEFAULT_LAYOUTS }
-			/>
+				isItemClickable={ isProductEditorAccessible }
+				renderItemLink={ ( { item, ...props } ) => (
+					<a
+						{ ...props }
+						href={ getAdminLink(
+							addQueryArgs( 'post.php', {
+								post: item.id,
+								action: 'edit',
+							} )
+						) }
+					>
+						{ props.children }
+					</a>
+				) }
+			>
+				<Stack
+					direction="row"
+					align="center"
+					justify="space-between"
+					gap="sm"
+					className="woocommerce-product-list__toolbar"
+				>
+					{ /* Tabs component should not be used: https://github.com/woocommerce/woocommerce/issues/64478 */ }
+					<Tabs.Root
+						value={ selectedTab }
+						onValueChange={ onChangeTab }
+					>
+						<Tabs.List
+							variant="minimal"
+							aria-label={ __(
+								'Filter products by status',
+								'woocommerce'
+							) }
+						>
+							{ PRODUCT_LIST_TABS.map( ( tab ) => (
+								<Tabs.Tab key={ tab.value } value={ tab.value }>
+									{ tab.label }
+								</Tabs.Tab>
+							) ) }
+						</Tabs.List>
+					</Tabs.Root>
+					<Stack direction="row" align="center" gap="xs">
+						<DataViews.Search
+							label={ __( 'Search products', 'woocommerce' ) }
+						/>
+						<DataViews.FiltersToggle />
+						<DataViews.LayoutSwitcher />
+						<DataViews.ViewConfig />
+					</Stack>
+				</Stack>
+				<DataViews.FiltersToggled />
+				<DataViews.Layout />
+				<DataViews.Footer />
+			</DataViews>
 		</Page>
 	);
 }
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 190ecf03aa8..1281e32c8f2 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
@@ -37,6 +37,11 @@ describe( 'buildProductListQuery', () => {
 		const query = buildProductListQuery( {
 			...baseView,
 			filters: [
+				{
+					field: 'product_status',
+					operator: 'is',
+					value: 'draft',
+				},
 				{
 					field: 'type',
 					operator: 'isAny',
@@ -62,6 +67,7 @@ describe( 'buildProductListQuery', () => {

 		expect( query ).toEqual(
 			expect.objectContaining( {
+				status: 'draft',
 				include_types: [ 'simple', 'variable' ],
 				category: '12,13',
 				stock_status: 'outofstock',
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 bbb09cc5746..357002a9358 100644
--- a/packages/js/experimental-products-app/src/product-list/query.ts
+++ b/packages/js/experimental-products-app/src/product-list/query.ts
@@ -8,9 +8,9 @@ import type {
 	ProductType,
 } from '@woocommerce/data';

-export type ProductListQuery = ProductQuery & {
+export type ProductListQuery = Omit< ProductQuery, 'status' > & {
+	status?: ProductStatus | ProductStatus[];
 	search_name_or_sku?: string;
-	include_status?: ProductStatus[];
 	exclude_status?: ProductStatus[];
 	include_types?: ProductType[];
 	exclude_types?: ProductType[];
@@ -77,7 +77,7 @@ function applyStatusFilter( query: ProductListQuery, filter: Filter ) {
 		return;
 	}

-	query.include_status = values;
+	query.status = values.length === 1 ? values[ 0 ] : values;
 }

 function applyTypeFilter( query: ProductListQuery, filter: Filter ) {
diff --git a/packages/js/experimental-products-app/src/product-list/utils.ts b/packages/js/experimental-products-app/src/product-list/utils.ts
new file mode 100644
index 00000000000..e458360ce38
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-list/utils.ts
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, ProductStatus } from '../fields/types';
+import { PRODUCT_LIST_TAB_VALUES, type StatusTab } from './constants';
+
+export function getProductListNavigationPath(
+	path: string,
+	params: Record< string, string >
+) {
+	const [ pathname = '/' ] = path.split( '?' );
+
+	return addQueryArgs( pathname, params );
+}
+
+export function getItemId( item: ProductEntityRecord ) {
+	return item.id.toString();
+}
+
+function isProductListTabValue( value: string ): value is StatusTab {
+	return PRODUCT_LIST_TAB_VALUES.includes( value as StatusTab );
+}
+
+export function getProductListTab( value?: string ): StatusTab {
+	if ( value && isProductListTabValue( value ) ) {
+		return value;
+	}
+
+	return 'all';
+}
+
+export function getStatusForProductListTab(
+	tab: StatusTab
+): ProductStatus | undefined {
+	switch ( tab ) {
+		case 'publish':
+		case 'draft':
+		case 'trash':
+			return tab;
+		default:
+			return undefined;
+	}
+}
+
+export function getSelectionFromPostId( postId?: string ) {
+	return postId?.split( ',' ).filter( Boolean ) ?? [];
+}
+
+export function isProductEditorAccessible( item: ProductEntityRecord ) {
+	return item.status !== 'trash';
+}
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 02125a21b6e..6c4dff50ba0 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -20,3 +20,11 @@
 .woocommerce-product-list {
 	height: 85vh;
 }
+
+.woocommerce-product-list__toolbar {
+	padding: 0 var(--wpds-dimension-padding-2xl, 24px);
+}
+
+.woocommerce-product-list .dataviews-wrapper {
+	height: 100%;
+}