Commit 1458129ec41 for woocommerce
commit 1458129ec41ab4e1d47eedd44cdb36ae6a7c9a4a
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Mon Mar 9 13:07:45 2026 +0100
Add reset notification email content (#63558)
* Email Editor: Add reset notification email content action
Registers a "Reset content to default" entity action for woo_email posts
in the email editor. When triggered, it fetches the original plugin-
distributed block template content via a new REST endpoint and restores
the post content to that default state.
- Add GET /woocommerce-email-editor/v1/emails/{id}/default-content endpoint
- Export registerEntityAction and PostWithPermissions from email editor package
- Register reset action for woo_email post type from email-editor-integration
* More route registration to the EmailApiController and remove support for WordPress 6.7 (Woo has discontinued it).
* Fix lint errors
* Fix lint error
* Enhance EmailApiController with new posts generator and default content schema
- Introduced WCTransactionalEmailPostsGenerator for managing email templates.
- Added get_default_content_schema method to define the schema for the default content endpoint response.
- Updated initialization logic to ensure both post manager and posts generator are set before generating email content.
diff --git a/packages/js/email-editor/changelog/wooprd-1019-export-register-entity-action b/packages/js/email-editor/changelog/wooprd-1019-export-register-entity-action
new file mode 100644
index 00000000000..32340cd1c34
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooprd-1019-export-register-entity-action
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Export registerEntityAction, unregisterEntityAction and PostWithPermissions from the email editor package public API.
diff --git a/packages/js/email-editor/src/index.ts b/packages/js/email-editor/src/index.ts
index b0f6d3a3c76..2d3dc4f579e 100644
--- a/packages/js/email-editor/src/index.ts
+++ b/packages/js/email-editor/src/index.ts
@@ -167,8 +167,18 @@ export type {
EmailEditorSettings,
EmailTheme,
EmailEditorUrls,
+ PostWithPermissions,
} from './store/types';
+/**
+ * The registerEntityAction and unregisterEntityAction are used to register and unregister entity actions.
+ * These use Gutenberg's private APIs and are highly unstable.
+ * DO NOT USE OUTSIDE WooCommerce.
+ *
+ * If necessary, import the unlock module and access the private APIs for your use case.
+ */
+export { registerEntityAction, unregisterEntityAction } from './private-apis';
+
/**
* A modal component for sending test emails from the email editor.
*
diff --git a/plugins/woocommerce/changelog/wooprd-1019-add-reset-notification-email-content b/plugins/woocommerce/changelog/wooprd-1019-add-reset-notification-email-content
new file mode 100644
index 00000000000..a90907c4b8d
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-1019-add-reset-notification-email-content
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add reset notification email content action to the email editor, allowing users to reset email content to the original plugin-distributed state.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
index 1d40a4c9cf8..87f5aedd04d 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
@@ -3,9 +3,12 @@
/**
* External dependencies
*/
-import { addFilter } from '@wordpress/hooks';
+import { addFilter, addAction } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
-import { initializeEditor } from '@woocommerce/email-editor';
+import {
+ initializeEditor,
+ registerEntityAction,
+} from '@woocommerce/email-editor';
/**
* Internal dependencies
@@ -14,6 +17,7 @@ import { NAME_SPACE } from './constants';
import { modifyTemplateSidebar } from './templates';
import { modifySidebar } from './sidebar_settings';
import { registerEmailValidationRules } from './email-validation';
+import getResetNotificationEmailContentAction from './reset-notification-email-content';
import './style.scss';
@@ -68,4 +72,26 @@ addFilter( 'woocommerce_email_editor_create_coupon_handler', NAME_SPACE, () => {
modifySidebar();
modifyTemplateSidebar();
registerEmailValidationRules();
+
+/**
+ * Register the reset notification email content entity action for the woo_email post type.
+ * This action allows users to reset the email content to the original state as distributed by the plugin.
+ */
+const registerResetNotificationEmailContentAction = ( postType: string ) => {
+ if ( postType !== 'woo_email' ) {
+ return;
+ }
+ registerEntityAction(
+ 'postType',
+ postType,
+ getResetNotificationEmailContentAction()
+ );
+};
+
+addAction(
+ 'core.registerPostTypeSchema',
+ `${ NAME_SPACE }/reset-notification-email-content`,
+ registerResetNotificationEmailContentAction
+);
+
initializeEditor( 'woocommerce-email-editor' );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
new file mode 100644
index 00000000000..9c96ce326a7
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
@@ -0,0 +1,183 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore } from '@wordpress/core-data';
+import { backup } from '@wordpress/icons';
+import { useState } from '@wordpress/element';
+import { useDispatch } from '@wordpress/data';
+import {
+ Button,
+ __experimentalText as Text,
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { decodeEntities } from '@wordpress/html-entities';
+import { parse, serialize } from '@wordpress/blocks';
+import apiFetch from '@wordpress/api-fetch';
+
+// eslint-disable-next-line @woocommerce/dependency-group
+import type { PostWithPermissions } from '@woocommerce/email-editor';
+
+function getItemTitle( item: {
+ title: string | { rendered: string } | { raw: string };
+} ) {
+ if ( typeof item.title === 'string' ) {
+ return decodeEntities( item.title );
+ }
+ if ( item.title && 'rendered' in item.title ) {
+ return decodeEntities( item.title.rendered );
+ }
+ if ( item.title && 'raw' in item.title ) {
+ return decodeEntities( item.title.raw );
+ }
+ return '';
+}
+
+const getResetNotificationEmailContentAction = () => {
+ /**
+ * Reset notification email content action.
+ * Resets a woo_email post content to the original state as distributed by the plugin.
+ */
+ const resetNotificationEmailContent = {
+ id: 'reset-notification-email-content',
+ label: __( 'Reset content to default', 'woocommerce' ),
+ supportsBulk: false,
+ icon: backup,
+ isEligible( item: PostWithPermissions ) {
+ if (
+ item.type === 'wp_template' ||
+ item.type === 'wp_template_part' ||
+ item.type === 'wp_block'
+ ) {
+ return false;
+ }
+ const { permissions } = item;
+ return permissions?.update;
+ },
+ hideModalHeader: true,
+ modalFocusOnMount: 'firstContentElement',
+ RenderModal: ( {
+ items,
+ closeModal,
+ onActionPerformed,
+ }: {
+ items: PostWithPermissions[];
+ closeModal?: () => void;
+ onActionPerformed?: ( items: PostWithPermissions[] ) => void;
+ } ) => {
+ const [ isBusy, setIsBusy ] = useState( false );
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+ const { editEntityRecord, saveEditedEntityRecord } =
+ useDispatch( coreStore );
+
+ const item = items[ 0 ];
+ const modalTitle = sprintf(
+ // translators: %s: The email's title
+ __(
+ 'Are you sure you want to reset "%s" content to the default?',
+ 'woocommerce'
+ ),
+ getItemTitle( item )
+ );
+
+ return (
+ <VStack spacing="5">
+ <Text>{ modalTitle }</Text>
+ <HStack justify="right">
+ <Button
+ variant="tertiary"
+ onClick={ () => {
+ closeModal?.();
+ } }
+ disabled={ isBusy }
+ __next40pxDefaultSize
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ variant="primary"
+ onClick={ async () => {
+ setIsBusy( true );
+
+ try {
+ const response = ( await apiFetch( {
+ path: `/woocommerce-email-editor/v1/emails/${ item.id }/default-content`,
+ } ) ) as { content: string };
+
+ const blocks = parse(
+ response.content || ''
+ );
+
+ await editEntityRecord(
+ 'postType',
+ item.type,
+ item.id,
+ {
+ blocks,
+ content: serialize( blocks ),
+ }
+ );
+
+ await saveEditedEntityRecord(
+ 'postType',
+ item.type,
+ item.id,
+ {}
+ );
+
+ const successMessage = sprintf(
+ /* translators: The email's title. */
+ __(
+ '"%s" content reset to default.',
+ 'woocommerce'
+ ),
+ getItemTitle( item )
+ );
+
+ createSuccessNotice( successMessage, {
+ type: 'snackbar',
+ id: 'reset-notification-email-content-action',
+ } );
+
+ onActionPerformed?.( items );
+ } catch ( error ) {
+ let errorMessage = __(
+ 'An error occurred while resetting the email content.',
+ 'woocommerce'
+ );
+
+ if (
+ error &&
+ typeof error === 'object' &&
+ 'message' in error
+ ) {
+ errorMessage = String( error.message );
+ }
+
+ createErrorNotice( errorMessage, {
+ type: 'snackbar',
+ } );
+ } finally {
+ setIsBusy( false );
+ closeModal?.();
+ }
+ } }
+ isBusy={ isBusy }
+ disabled={ isBusy }
+ __next40pxDefaultSize
+ >
+ { __( 'Reset to default', 'woocommerce' ) }
+ </Button>
+ </HStack>
+ </VStack>
+ );
+ },
+ };
+
+ return resetNotificationEmailContent;
+};
+
+export default getResetNotificationEmailContentAction;
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 2f1201e82b3..3e0e71957fa 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -6,8 +6,11 @@ namespace Automattic\WooCommerce\Internal\EmailEditor;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use WC_Email;
use WP_Error;
+use WP_REST_Request;
+use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
@@ -25,13 +28,21 @@ class EmailApiController {
*/
private ?WCTransactionalEmailPostsManager $post_manager;
+ /**
+ * The WooCommerce transactional email posts generator.
+ *
+ * @var WCTransactionalEmailPostsGenerator|null
+ */
+ private ?WCTransactionalEmailPostsGenerator $posts_generator = null;
+
/**
* Initialize the controller.
*
* @internal
*/
final public function init(): void {
- $this->post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $this->post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $this->posts_generator = new WCTransactionalEmailPostsGenerator();
}
/**
@@ -247,4 +258,84 @@ class EmailApiController {
}
return null;
}
+
+ /**
+ * Register REST API routes for the email API controller.
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ 'woocommerce-email-editor/v1',
+ '/emails/(?P<id>\d+)/default-content',
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_default_content_response' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_woocommerce' );
+ },
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The ID of the woo_email post.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ ),
+ 'schema' => array( $this, 'get_default_content_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Get the schema for the default content endpoint response.
+ *
+ * @return array
+ */
+ public function get_default_content_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'woo_email_default_content',
+ 'type' => 'object',
+ 'properties' => array(
+ 'content' => array(
+ 'description' => __( 'The default block content for the email.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Return the default (plugin-distributed) block content for a woo_email post.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_default_content_response( WP_REST_Request $request ) {
+ if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+ return new WP_Error(
+ 'woocommerce_email_editor_not_initialized',
+ __( 'Email editor is not initialized.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $post_id = (int) $request->get_param( 'id' );
+ $email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+ $email = $this->get_email_by_type( $email_type ?? '' );
+
+ if ( ! $email ) {
+ return new WP_Error(
+ 'woocommerce_email_not_found',
+ __( 'No email found for the given post ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return new WP_REST_Response(
+ array( 'content' => $this->posts_generator->get_email_template( $email ) ),
+ 200
+ );
+ }
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index bb46aeddd5a..dcc7f8f73d2 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -140,6 +140,7 @@ class Integration {
add_action( 'woocommerce_email_editor_send_preview_email_before_wp_mail', array( $this, 'send_preview_email_before_wp_mail' ), 10 );
add_action( 'woocommerce_email_editor_send_preview_email_after_wp_mail', array( $this, 'send_preview_email_after_wp_mail' ), 10 );
add_filter( 'woocommerce_email_editor_send_preview_email_subject', array( $this, 'update_email_subject_for_send_preview_email' ), 10, 2 );
+ add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index f4193373801..b410a1967e0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
use Automattic\WooCommerce\Internal\EmailEditor\EmailApiController;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
require_once 'EmailStub.php';
@@ -280,4 +281,61 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertEquals( $this->email_type, $result['email_type'] );
$this->assertEquals( 'Default Subject', $result['default_subject'] );
}
+
+ /**
+ * @testdox Should return 404 when post ID has no associated email type.
+ */
+ public function test_get_default_content_response_returns_404_for_unknown_post(): void {
+ $unassociated_post = $this->factory()->post->create_and_get(
+ array(
+ 'post_title' => 'Unknown Email',
+ 'post_name' => 'unknown_email',
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'draft',
+ )
+ );
+
+ $request = new \WP_REST_Request( 'GET', '/woocommerce-email-editor/v1/emails/' . $unassociated_post->ID . '/default-content' );
+ $request->set_param( 'id', $unassociated_post->ID );
+
+ $result = $this->email_api_controller->get_default_content_response( $request );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'woocommerce_email_not_found', $result->get_error_code() );
+ $this->assertSame( 404, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * @testdox Should return default content for a valid email post.
+ */
+ public function test_get_default_content_response_returns_content_for_valid_post(): void {
+ $mock_email = $this->createMock( \WC_Email::class );
+ $mock_email->id = $this->email_type;
+
+ $mock_generator = $this->createMock( WCTransactionalEmailPostsGenerator::class );
+ $mock_generator->method( 'get_email_template' )
+ ->willReturn( '<!-- wp:paragraph --><p>Default content</p><!-- /wp:paragraph -->' );
+
+ $controller = $this->getMockBuilder( EmailApiController::class )
+ ->onlyMethods( array( 'get_emails' ) )
+ ->getMock();
+ $controller->method( 'get_emails' )
+ ->willReturn( array( $mock_email ) );
+ $controller->init();
+
+ $reflection = new \ReflectionClass( EmailApiController::class );
+ $property = $reflection->getProperty( 'posts_generator' );
+ $property->setAccessible( true );
+ $property->setValue( $controller, $mock_generator );
+
+ $request = new \WP_REST_Request( 'GET', '/woocommerce-email-editor/v1/emails/' . $this->email_post->ID . '/default-content' );
+ $request->set_param( 'id', $this->email_post->ID );
+
+ $result = $controller->get_default_content_response( $request );
+
+ $this->assertInstanceOf( \WP_REST_Response::class, $result );
+ $this->assertSame( 200, $result->get_status() );
+ $this->assertArrayHasKey( 'content', $result->get_data() );
+ $this->assertSame( '<!-- wp:paragraph --><p>Default content</p><!-- /wp:paragraph -->', $result->get_data()['content'] );
+ }
}