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 ]
);