Commit 65f4e3130a8 for woocommerce

commit 65f4e3130a8e4a529406937efe6a48a5d478929f
Author: verofasulo <98944206+verofasulo@users.noreply.github.com>
Date:   Wed May 20 14:03:51 2026 +0200

    Add Restore and Permanently delete actions to trashed products (#65008)

    Adds two new DataViews actions in the experimental products app, surfaced
    only when a product's status is `trash`:

    - Restore: flips the status from `trash` back to `draft` via
      editEntityRecord + saveEditedEntityRecord, and invalidates the
      getEntityRecords resolution so the Trash tab refreshes.
    - Permanently delete: opens a confirmation modal (singular/plural copy)
      and, on confirm, deletes the entity with `force: true`.

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    Co-authored-by: Luigi Teschio <gigitux@gmail.com>

diff --git a/packages/js/experimental-products-app/changelog/add-products-trash-actions b/packages/js/experimental-products-app/changelog/add-products-trash-actions
new file mode 100644
index 00000000000..6f297b11016
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-trash-actions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Restore and Permanently delete actions to trashed products in the experimental products app
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
index 88b6c593eae..13dcf5daa8a 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
@@ -3,8 +3,9 @@
  */
 import apiFetch from '@wordpress/api-fetch';
 import { dispatch } from '@wordpress/data';
-import type { Action } from '@wordpress/dataviews';
+import type { Action, ActionModal } from '@wordpress/dataviews';
 import { renderHook } from '@testing-library/react';
+import React from 'react';

 /**
  * Internal dependencies
@@ -12,6 +13,8 @@ import { renderHook } from '@testing-library/react';
 import {
 	duplicateProductAction,
 	moveToTrashAction,
+	permanentlyDeleteAction,
+	restoreAction,
 	quickEditAction,
 	selectAllVariationsAction,
 	useProductActions,
@@ -20,6 +23,16 @@ import type { ProductEntityRecord } from '../fields/types';

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

+jest.mock( '@wordpress/components', () => ( {
+	Button: 'button',
+	__experimentalHStack: ( { children }: { children: React.ReactNode } ) =>
+		children,
+	__experimentalText: ( { children }: { children: React.ReactNode } ) =>
+		children,
+	__experimentalVStack: ( { children }: { children: React.ReactNode } ) =>
+		children,
+} ) );
+
 jest.mock( '@wordpress/core-data', () => ( {
 	store: 'mock-core-store',
 } ) );
@@ -128,6 +141,8 @@ describe( 'product list actions', () => {
 	} as ProductEntityRecord;

 	const deleteEntityRecord = jest.fn();
+	const editEntityRecord = jest.fn();
+	const saveEditedEntityRecord = jest.fn();
 	const invalidateResolution = jest.fn();
 	const invalidateResolutionForStoreSelector = jest.fn();
 	const createSuccessNotice = jest.fn();
@@ -151,6 +166,8 @@ describe( 'product list actions', () => {
 			if ( storeName === 'mock-core-store' ) {
 				return {
 					deleteEntityRecord,
+					editEntityRecord,
+					saveEditedEntityRecord,
 					invalidateResolution,
 					invalidateResolutionForStoreSelector,
 				};
@@ -406,4 +423,116 @@ describe( 'product list actions', () => {
 		expect( onActionPerformed ).toHaveBeenCalledWith( [ product ] );
 		expect( createErrorNotice ).not.toHaveBeenCalled();
 	} );
+
+	describe( 'restoreAction', () => {
+		const trashedProduct = {
+			...product,
+			status: 'trash',
+		} as ProductEntityRecord;
+
+		it( 'is eligible only for trashed products', () => {
+			const action = restoreAction();
+
+			expect( action.isEligible?.( trashedProduct ) ).toBe( true );
+			expect( action.isEligible?.( product ) ).toBe( false );
+		} );
+
+		it( 'restores products by saving status as draft and invalidates the query', async () => {
+			saveEditedEntityRecord.mockResolvedValue( {
+				id: 12,
+				status: 'draft',
+			} );
+
+			await getCallbackAction( restoreAction() ).callback(
+				[ trashedProduct ],
+				{ onActionPerformed }
+			);
+
+			expect( editEntityRecord ).toHaveBeenCalledWith(
+				'root',
+				'product',
+				12,
+				{ status: 'draft' }
+			);
+			expect( saveEditedEntityRecord ).toHaveBeenCalledWith(
+				'root',
+				'product',
+				12,
+				{ throwOnError: true }
+			);
+			expect( invalidateResolutionForStoreSelector ).toHaveBeenCalledWith(
+				'getEntityRecords'
+			);
+			expect( createSuccessNotice ).toHaveBeenCalledWith(
+				'Product successfully restored',
+				{ type: 'snackbar' }
+			);
+			expect( onActionPerformed ).toHaveBeenCalledWith( [
+				trashedProduct,
+			] );
+			expect( createErrorNotice ).not.toHaveBeenCalled();
+		} );
+
+		it( 'shows an error notice when restore fails', async () => {
+			saveEditedEntityRecord.mockRejectedValueOnce(
+				new Error( 'Restore failed' )
+			);
+
+			await getCallbackAction( restoreAction() ).callback(
+				[ trashedProduct ],
+				{ onActionPerformed }
+			);
+
+			expect( createSuccessNotice ).not.toHaveBeenCalled();
+			expect( createErrorNotice ).toHaveBeenCalledWith(
+				'Restore failed',
+				{
+					type: 'snackbar',
+				}
+			);
+			expect( onActionPerformed ).not.toHaveBeenCalled();
+		} );
+	} );
+
+	describe( 'permanentlyDeleteAction', () => {
+		const trashedProduct = {
+			...product,
+			status: 'trash',
+		} as ProductEntityRecord;
+
+		const getModalAction = (
+			action: Action< ProductEntityRecord >
+		): ActionModal< ProductEntityRecord > =>
+			action as ActionModal< ProductEntityRecord >;
+
+		it( 'is eligible only for trashed products', () => {
+			const action = permanentlyDeleteAction();
+
+			expect( action.isEligible?.( trashedProduct ) ).toBe( true );
+			expect( action.isEligible?.( product ) ).toBe( false );
+		} );
+
+		it( 'uses a singular modal header for a single product', () => {
+			const action = getModalAction( permanentlyDeleteAction() );
+			const header = action.modalHeader;
+
+			expect( typeof header ).toBe( 'function' );
+			expect(
+				typeof header === 'function'
+					? header( [ trashedProduct ] )
+					: header
+			).toBe( 'Delete product?' );
+		} );
+
+		it( 'uses a plural modal header for multiple products', () => {
+			const action = getModalAction( permanentlyDeleteAction() );
+			const header = action.modalHeader;
+
+			expect(
+				typeof header === 'function'
+					? header( [ trashedProduct, trashedProduct ] )
+					: header
+			).toBe( 'Delete products?' );
+		} );
+	} );
 } );
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
index 5dc6d971b12..eb8531965ad 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
@@ -2,9 +2,15 @@
  * External dependencies
  */
 import apiFetch from '@wordpress/api-fetch';
+import {
+	Button,
+	__experimentalHStack as HStack,
+	__experimentalText as Text,
+	__experimentalVStack as VStack,
+} from '@wordpress/components';
 import { store as coreStore } from '@wordpress/core-data';
 import { dispatch } from '@wordpress/data';
-import { edit, trash } from '@wordpress/icons';
+import { backup, edit, trash } from '@wordpress/icons';
 import { __, _n, sprintf } from '@wordpress/i18n';
 import { store as noticesStore } from '@wordpress/notices';
 import { privateApis as routerPrivateApis } from '@wordpress/router';
@@ -408,6 +414,180 @@ export const moveToTrashAction = (): Action< ProductEntityRecord > => ( {
 	},
 } );

+export const restoreAction = (): Action< ProductEntityRecord > => ( {
+	id: 'restore-product',
+	label: __( 'Restore', 'woocommerce' ),
+	supportsBulk: true,
+	icon: backup,
+	isEligible( product ) {
+		return product.status === 'trash';
+	},
+	async callback( items, { onActionPerformed } ) {
+		const {
+			editEntityRecord,
+			saveEditedEntityRecord,
+			invalidateResolutionForStoreSelector,
+		} = dispatch( coreStore );
+		const { createErrorNotice, createSuccessNotice } =
+			dispatch( noticesStore );
+
+		const results = await Promise.allSettled(
+			items.map( async ( product ) => {
+				await editEntityRecord( 'root', 'product', product.id, {
+					status: 'draft',
+				} );
+				return saveEditedEntityRecord( 'root', 'product', product.id, {
+					throwOnError: true,
+				} );
+			} )
+		);
+		const successfulItems = getSuccessfulItems( items, results );
+		const failedResults = results.filter(
+			( result ) => result.status === 'rejected'
+		);
+
+		if ( successfulItems.length > 0 ) {
+			await invalidateResolutionForStoreSelector( 'getEntityRecords' );
+			createSuccessNotice(
+				successfulItems.length === 1
+					? __( 'Product successfully restored', 'woocommerce' )
+					: sprintf(
+							/* translators: %s: number of products. */
+							_n(
+								'%s product successfully restored',
+								'%s products successfully restored',
+								successfulItems.length,
+								'woocommerce'
+							),
+							successfulItems.length
+					  ),
+				{ type: 'snackbar' }
+			);
+			onActionPerformed?.( successfulItems );
+		}
+
+		if ( failedResults.length > 0 ) {
+			createErrorNotice(
+				getErrorMessage(
+					( failedResults[ 0 ] as PromiseRejectedResult ).reason
+				),
+				{ type: 'snackbar' }
+			);
+		}
+	},
+} );
+
+export const permanentlyDeleteAction = (): Action< ProductEntityRecord > => ( {
+	id: 'permanently-delete-product',
+	label: __( 'Permanently delete', 'woocommerce' ),
+	supportsBulk: true,
+	icon: trash,
+	isEligible( product ) {
+		return product.status === 'trash';
+	},
+	modalHeader: ( items ) =>
+		items.length === 1
+			? __( 'Delete product?', 'woocommerce' )
+			: __( 'Delete products?', 'woocommerce' ),
+	RenderModal: ( { items, closeModal, onActionPerformed } ) => {
+		const onConfirm = async () => {
+			const { deleteEntityRecord, invalidateResolutionForStoreSelector } =
+				dispatch( coreStore );
+			const { createErrorNotice, createSuccessNotice } =
+				dispatch( noticesStore );
+
+			const results = await Promise.allSettled(
+				items.map( ( product ) =>
+					deleteEntityRecord( 'root', 'product', product.id, {
+						force: true,
+						throwOnError: true,
+					} )
+				)
+			);
+			const successfulItems = getSuccessfulItems( items, results );
+			const failedResults = results.filter(
+				( result ) => result.status === 'rejected'
+			);
+
+			if ( successfulItems.length > 0 ) {
+				await invalidateResolutionForStoreSelector(
+					'getEntityRecords'
+				);
+				createSuccessNotice(
+					successfulItems.length === 1
+						? __( 'Product permanently deleted', 'woocommerce' )
+						: sprintf(
+								/* translators: %s: number of products. */
+								_n(
+									'%s product permanently deleted',
+									'%s products permanently deleted',
+									successfulItems.length,
+									'woocommerce'
+								),
+								successfulItems.length
+						  ),
+					{ type: 'snackbar' }
+				);
+				onActionPerformed?.( successfulItems );
+			}
+
+			if ( failedResults.length > 0 ) {
+				createErrorNotice(
+					getErrorMessage(
+						( failedResults[ 0 ] as PromiseRejectedResult ).reason
+					),
+					{ type: 'snackbar' }
+				);
+			}
+
+			closeModal?.();
+		};
+
+		return (
+			<VStack spacing="5">
+				<Text>
+					{ items.length === 1
+						? sprintf(
+								/* translators: %s: The product's name. */
+								__(
+									'"%s" will be permanently deleted and can\'t be restored.',
+									'woocommerce'
+								),
+								items[ 0 ]?.name ?? ''
+						  )
+						: sprintf(
+								/* translators: %s: number of products. */
+								_n(
+									"%s product will be permanently deleted and can't be restored.",
+									"%s products will be permanently deleted and can't be restored.",
+									items.length,
+									'woocommerce'
+								),
+								items.length
+						  ) }
+				</Text>
+				<HStack justify="flex-end">
+					<Button
+						__next40pxDefaultSize
+						variant="tertiary"
+						onClick={ closeModal }
+					>
+						{ __( 'Cancel', 'woocommerce' ) }
+					</Button>
+					<Button
+						__next40pxDefaultSize
+						variant="primary"
+						isDestructive
+						onClick={ onConfirm }
+					>
+						{ __( 'Delete permanently', 'woocommerce' ) }
+					</Button>
+				</HStack>
+			</VStack>
+		);
+	},
+} );
+
 export const useProductActions = () => {
 	const { navigate } = useHistory();
 	const { path, query = {} } = useLocation();
@@ -427,6 +607,8 @@ export const useProductActions = () => {
 			} ),
 			duplicateProductAction(),
 			moveToTrashAction(),
+			restoreAction(),
+			permanentlyDeleteAction(),
 		],
 		[ navigate, path, query ]
 	);