Commit e324e9c31b for woocommerce

commit e324e9c31b76f78045399460cb1cae761a0466ad
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Thu Jan 29 13:09:44 2026 +0100

    Email Editor: Ensure reset template functioality (#62984)

    * Replace email template actions with custom reset template action

    WOOPRD-1074

    * Move move-to-trash.txt to hacks folder

    This functionality is not related to core blocks so I'm moving to
    a better-suited location.

    * Add E2E test for reset template functionality

    * Add changelogs

    * Address Coderabbit's recommendations

    * Remove unnecessary store update in reset template

    * Apply template actions only to template posts

    * Add documentation for hacks

    * Fix the import of type PostWithPermissions

diff --git a/packages/js/email-editor/changelog/wooprd-1074-fix-reset-template-was-removed b/packages/js/email-editor/changelog/wooprd-1074-fix-reset-template-was-removed
new file mode 100644
index 0000000000..29ee8aaeef
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooprd-1074-fix-reset-template-was-removed
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Ensure reset template action for email templates
diff --git a/packages/js/email-editor/src/blocks/index.ts b/packages/js/email-editor/src/blocks/index.ts
index f1d5dbc224..835a609508 100644
--- a/packages/js/email-editor/src/blocks/index.ts
+++ b/packages/js/email-editor/src/blocks/index.ts
@@ -27,7 +27,6 @@ import {
 import { enhanceQuoteBlock } from './core/quote';
 import { filterSetUrlAttribute } from './core/block-edit';
 import { enhanceSocialLinksBlock } from './core/social-links';
-import { modifyMoveToTrashAction } from './core/move-to-trash';
 import { enhanceSiteLogoBlock } from './core/site-logo';

 export { getAllowedBlockNames } from './utils';
@@ -52,7 +51,6 @@ export function initBlocks() {
 	activatePersonalizationTagsReplacing();
 	alterSupportConfiguration();
 	enhanceSocialLinksBlock();
-	modifyMoveToTrashAction();
 	enhanceSiteLogoBlock();
 	removeBlockStylesFromAllBlocks();
 }
diff --git a/packages/js/email-editor/src/components/sidebar/reset-email-template.tsx b/packages/js/email-editor/src/components/sidebar/reset-email-template.tsx
new file mode 100644
index 0000000000..387846f794
--- /dev/null
+++ b/packages/js/email-editor/src/components/sidebar/reset-email-template.tsx
@@ -0,0 +1,223 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore, type WpTemplate } 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';
+
+/**
+ * Internal dependencies
+ */
+import { type PostWithPermissions } from '../../store';
+import { recordEvent } from '../../events';
+
+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 getResetEmailTemplateAction = () => {
+	/**
+	 * Reset email template action.
+	 * Resets a wp_template to its default state by deleting the customized version.
+	 */
+	const resetEmailTemplate = {
+		id: 'reset-email-template',
+		label: __( 'Reset', 'woocommerce' ),
+		supportsBulk: false,
+		icon: backup,
+		isEligible( item: PostWithPermissions ) {
+			// Only for wp_template post type
+			if ( item.type !== 'wp_template' ) {
+				return false;
+			}
+			// Match Gutenberg's isTemplateRevertible logic:
+			// Must be customized AND have an original to revert to
+			if ( item.source !== 'custom' ) {
+				return false;
+			}
+			if (
+				! ( Boolean( item.plugin ) || Boolean( item.has_theme_file ) )
+			) {
+				return false;
+			}
+			const { permissions } = item;
+			return permissions?.delete;
+		},
+		hideModalHeader: true,
+		modalFocusOnMount: 'firstContentElement',
+		RenderModal: ( { items, closeModal, onActionPerformed } ) => {
+			const [ isBusy, setIsBusy ] = useState( false );
+			const { createSuccessNotice, createErrorNotice } =
+				useDispatch( noticesStore );
+			const {
+				invalidateResolution,
+				editEntityRecord,
+				saveEditedEntityRecord,
+			} = useDispatch( coreStore );
+
+			const item = items[ 0 ];
+			const modalTitle = sprintf(
+				// translators: %s: The template's title
+				__(
+					'Are you sure you want to reset "%s" to default?',
+					'woocommerce'
+				),
+				getItemTitle( item )
+			);
+
+			return (
+				<VStack spacing="5">
+					<Text>{ modalTitle }</Text>
+					<HStack justify="right">
+						<Button
+							variant="tertiary"
+							onClick={ () => {
+								closeModal?.();
+								recordEvent(
+									'reset_modal_cancel_button_clicked'
+								);
+							} }
+							disabled={ isBusy }
+							__next40pxDefaultSize
+						>
+							{ __( 'Cancel', 'woocommerce' ) }
+						</Button>
+						<Button
+							variant="primary"
+							onClick={ async () => {
+								recordEvent(
+									'reset_modal_confirm_button_clicked'
+								);
+								setIsBusy( true );
+
+								try {
+									// Fetch the original template from theme/plugin file
+									// Uses the origin field to get the original source version
+									const fileTemplate = ( await apiFetch( {
+										path: `/wp/v2/templates/${ item.id }?context=edit&source=${ item.origin }`,
+									} ) ) as WpTemplate;
+
+									// Parse blocks from the original template content
+									const blocks = parse(
+										fileTemplate.content?.raw || ''
+									);
+
+									// Apply the reset with original blocks
+									editEntityRecord(
+										'postType',
+										item.type,
+										item.id,
+										{
+											blocks,
+											content: serialize( blocks ),
+											source: item.origin,
+										}
+									);
+
+									// Save the entity to persist the reset and clear dirty state
+									await saveEditedEntityRecord(
+										'postType',
+										item.type,
+										item.id,
+										{}
+									);
+
+									// Delete the custom database post so WordPress falls back to the file version
+									// This ensures source becomes 'plugin'/'theme' instead of staying 'custom'
+									await apiFetch( {
+										path: `/wp/v2/templates/${ item.id }`,
+										method: 'DELETE',
+									} );
+
+									// Invalidate to ensure editor and actions menu see the file version
+									invalidateResolution( 'getEntityRecord', [
+										'postType',
+										item.type,
+										item.id,
+									] );
+
+									const successMessage = sprintf(
+										/* translators: The template's title. */
+										__(
+											'"%s" reset to default.',
+											'woocommerce'
+										),
+										getItemTitle( item )
+									);
+
+									createSuccessNotice( successMessage, {
+										type: 'snackbar',
+										id: 'reset-email-template-action',
+									} );
+
+									onActionPerformed?.( items );
+									setIsBusy( false );
+									closeModal?.();
+								} catch ( error ) {
+									let errorMessage = __(
+										'An error occurred while resetting the template.',
+										'woocommerce'
+									);
+
+									if (
+										error &&
+										typeof error === 'object' &&
+										'message' in error
+									) {
+										errorMessage = String( error.message );
+									}
+
+									recordEvent( 'reset_modal_error', {
+										errorMessage,
+									} );
+
+									createErrorNotice( errorMessage, {
+										type: 'snackbar',
+									} );
+
+									setIsBusy( false );
+									closeModal?.();
+								}
+							} }
+							isBusy={ isBusy }
+							disabled={ isBusy }
+							__next40pxDefaultSize
+						>
+							{ __( 'Reset', 'woocommerce' ) }
+						</Button>
+					</HStack>
+				</VStack>
+			);
+		},
+	};
+
+	return resetEmailTemplate;
+};
+
+/**
+ * Reset email template action for PostWithPermissions.
+ */
+export default getResetEmailTemplateAction;
diff --git a/packages/js/email-editor/src/editor.tsx b/packages/js/email-editor/src/editor.tsx
index 2272827154..3987b70349 100644
--- a/packages/js/email-editor/src/editor.tsx
+++ b/packages/js/email-editor/src/editor.tsx
@@ -28,6 +28,7 @@ import {
 	initDomTracking,
 } from './events';
 import { initContentValidationMiddleware } from './middleware/content-validation';
+import { initHacks } from './hacks';
 import {
 	useContentValidation,
 	useRemoveSavingFailedNotices,
@@ -101,6 +102,7 @@ function onInit() {
 	createStore();
 	initContentValidationMiddleware();
 	initBlocks();
+	initHacks();
 	initTextHooks();
 	initializeLayout();
 }
diff --git a/packages/js/email-editor/src/hacks/README.md b/packages/js/email-editor/src/hacks/README.md
new file mode 100644
index 0000000000..a8a85be919
--- /dev/null
+++ b/packages/js/email-editor/src/hacks/README.md
@@ -0,0 +1,66 @@
+# Email Editor Hacks
+
+We intentionally call these "hacks" because they are workarounds for missing upstream APIs in WordPress/Gutenberg. **Our goal is to work upstream with the WordPress and Gutenberg teams to get proper APIs added so we can remove these workarounds.**
+
+Each hack below represents functionality that should ideally be supported through official WordPress/Gutenberg APIs.
+
+## Current Hacks
+
+### 1. Notices Slot ([notices-slot.tsx](notices-slot.tsx))
+
+**Purpose:** Renders a portal for displaying notices before the visual editor.
+
+**Missing Upstream API:** There is currently no API to add notices with custom context to the content area in the block editor.
+
+**What it does:** Creates a DOM portal that inserts a container as the first child of the visual editor, allowing notices to be rendered in a custom location.
+
+**Upstream solution needed:** An official API for registering custom notice contexts and controlling notice placement within the editor.
+
+---
+
+### 2. Publish/Save Button Management ([publish-save.tsx](publish-save.tsx))
+
+**Purpose:** Conditionally hides the default publish button and adds a custom "Send" button for email posts.
+
+**Missing Upstream API:** No official way to customize or replace the publish button behavior based on specific post type requirements.
+
+**What it does:**
+
+- Uses DOM manipulation and MutationObserver to hide/show the default publish button
+- Creates a portal to render a custom "Send" button next to the publish button
+- Determines visibility based on whether changes are in the post, template, or both
+
+**Upstream solution needed:** An official API for customizing or replacing the publish button, including the ability to register custom save actions for specific post types.
+
+---
+
+### 3. Move to Trash Action ([move-to-trash.tsx](move-to-trash.tsx))
+
+**Purpose:** Replaces the default "Move to Trash" action with a custom implementation for email posts.
+
+**Missing Upstream API:** While entity actions can be registered, there's no clean way to customize the built-in trash behavior without unregistering and re-registering.
+
+**What it does:**
+
+- Unregisters the default `move-to-trash` action
+- Registers a custom trash action specific to email posts
+- Hooks into both `core.registerPostTypeSchema` (WP 6.8+) and `core.registerPostTypeActions` (WP 6.7+) for compatibility
+
+**Upstream solution needed:** A filter or configuration option to customize built-in entity actions without needing to unregister and re-register them.
+
+---
+
+### 4. Template Actions Modification ([modify-template-actions.tsx](modify-template-actions.tsx))
+
+**Purpose:** Adapts template actions for email templates to work around the "Active Templates" feature introduced in WordPress/Gutenberg.
+
+**What it does:**
+
+- Removes the default "Duplicate" action (not needed for email templates)
+- Removes the default Gutenberg "Reset" action
+- Registers a custom reset template action that maintains previous behavior
+- Applies modifications only to `wp_template` post type
+
+**Solution needed:** We need to find out how to align the email editor with the Active Templates feature. We should aim to integrate with the Site editor to add support for email templates and that way we could support the template management and new actions introduced in the Active Templates feature.
+
+---
diff --git a/packages/js/email-editor/src/hacks/index.ts b/packages/js/email-editor/src/hacks/index.ts
new file mode 100644
index 0000000000..37f9e05f69
--- /dev/null
+++ b/packages/js/email-editor/src/hacks/index.ts
@@ -0,0 +1,21 @@
+/**
+ * Initialize hacks for the email editor.
+ *
+ * This module contains temporary workarounds and fixes for compatibility issues
+ * with WordPress/Gutenberg features. These should be reviewed and potentially
+ * removed when better long-term solutions are available.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { modifyTemplateActions } from './modify-template-actions';
+import { modifyMoveToTrashAction } from './move-to-trash';
+
+/**
+ * Initialize all hacks.
+ */
+export function initHacks() {
+	modifyMoveToTrashAction();
+	modifyTemplateActions();
+}
diff --git a/packages/js/email-editor/src/hacks/modify-template-actions.tsx b/packages/js/email-editor/src/hacks/modify-template-actions.tsx
new file mode 100644
index 0000000000..75176d2311
--- /dev/null
+++ b/packages/js/email-editor/src/hacks/modify-template-actions.tsx
@@ -0,0 +1,65 @@
+/**
+ * Modify template actions for email templates.
+ *
+ * This is a workaround for the new "Active Templates" feature in WordPress/Gutenberg.
+ * When the Active Templates feature is enabled, it introduces new template actions
+ * that don't align well with the email editor's requirements. The email editor currently
+ * does not have template management capabilities so we miss tools needed for Active Templates.
+ *
+ * This solution maintains the previous reset template behavior until we identify a
+ * better long-term approach that aligns with WordPress core's Active Templates feature.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { registerEntityAction, unregisterEntityAction } from '../private-apis';
+import { addActionForEmail } from '../config-tools/filters';
+import getResetEmailTemplateAction from '../components/sidebar/reset-email-template';
+
+/**
+ * Remove the default duplicate action and add custom reset action for email templates.
+ *
+ * @param postType - The post type to modify actions for.
+ */
+const modifyActionsForPostType = ( postType: string ) => {
+	// Only modify actions for wp_template post type.
+	if ( postType !== 'wp_template' ) {
+		return;
+	}
+	// Remove the default duplicate action.
+	unregisterEntityAction( 'postType', postType, 'duplicate-post' );
+
+	// Remove the default Gutenberg reset action.
+	unregisterEntityAction( 'postType', postType, 'reset-post' );
+
+	// Add the custom reset email template action.
+	registerEntityAction( 'postType', postType, getResetEmailTemplateAction() );
+};
+
+/**
+ * Modify template actions for email editor.
+ * - Removes duplicate action (not needed for email templates)
+ * - Replaces Gutenberg's reset action with custom implementation
+ */
+function modifyTemplateActions() {
+	// Available in WordPress 6.8+
+	addActionForEmail(
+		'core.registerPostTypeSchema',
+		'woocommerce-email-editor/modify-template-actions',
+		( postType ) => {
+			modifyActionsForPostType( postType );
+		}
+	);
+
+	// Support for WordPress 6.7+
+	addActionForEmail(
+		'core.registerPostTypeActions',
+		'woocommerce-email-editor/modify-template-actions',
+		( postType ) => {
+			modifyActionsForPostType( postType );
+		}
+	);
+}
+
+export { modifyTemplateActions };
diff --git a/packages/js/email-editor/src/blocks/core/move-to-trash.tsx b/packages/js/email-editor/src/hacks/move-to-trash.tsx
similarity index 79%
rename from packages/js/email-editor/src/blocks/core/move-to-trash.tsx
rename to packages/js/email-editor/src/hacks/move-to-trash.tsx
index 3cac6d3ccc..2363abd1fa 100644
--- a/packages/js/email-editor/src/blocks/core/move-to-trash.tsx
+++ b/packages/js/email-editor/src/hacks/move-to-trash.tsx
@@ -1,12 +1,9 @@
 /**
  * Internal dependencies
  */
-import {
-	registerEntityAction,
-	unregisterEntityAction,
-} from '../../private-apis';
-import { addActionForEmail } from '../../config-tools/filters';
-import getTrashEmailPostAction from '../../components/header/trash-email-post';
+import { registerEntityAction, unregisterEntityAction } from '../private-apis';
+import { addActionForEmail } from '../config-tools/filters';
+import getTrashEmailPostAction from '../components/header/trash-email-post';

 const removeDefaultMoveToTrashActionAddCustom = ( postType: string ) => {
 	// Remove the default move to trash action.
diff --git a/packages/js/email-editor/src/store/types.ts b/packages/js/email-editor/src/store/types.ts
index d22760f5b7..14f53960d3 100644
--- a/packages/js/email-editor/src/store/types.ts
+++ b/packages/js/email-editor/src/store/types.ts
@@ -198,6 +198,10 @@ export type PostWithPermissions = Post & {
 		delete: boolean;
 		update: boolean;
 	};
+	source?: 'custom' | 'theme' | 'plugin';
+	origin?: string;
+	plugin?: string;
+	has_theme_file?: boolean;
 };

 export type EmailEditorConfig = {
diff --git a/plugins/woocommerce/changelog/wooprd-1074-fix-reset-template-was-removed b/plugins/woocommerce/changelog/wooprd-1074-fix-reset-template-was-removed
new file mode 100644
index 0000000000..a606bb9a00
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-1074-fix-reset-template-was-removed
@@ -0,0 +1,5 @@
+Significance: patch
+Type: enhancement
+Comment: Added E2E tests for email editor reset template functionality
+
+
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/email-editor-reset-template.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/email-editor-reset-template.spec.js
new file mode 100644
index 0000000000..8c1dc127f1
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/email-editor-reset-template.spec.js
@@ -0,0 +1,154 @@
+const { test, expect } = require( '@playwright/test' );
+const { ADMIN_STATE_PATH } = require( '../../playwright.config' );
+const {
+	enableEmailEditor,
+	disableEmailEditor,
+	resetWCTransactionalEmail,
+} = require( './helpers/enable-email-editor-feature' );
+const { accessTheEmailEditor } = require( '../../utils/email' );
+
+/**
+ * Helper function to switch from email editor to template editing mode.
+ *
+ * @param {import('@playwright/test').Page} page The Playwright page.
+ */
+async function switchToTemplateEditingMode( page ) {
+	// Open the Settings panel if not already open
+	const settingsPanel = page.locator(
+		'.woocommerce-email-editor__settings-panel'
+	);
+	const isPanelExpanded = await settingsPanel.evaluate( ( elem ) =>
+		elem.classList.contains( 'is-opened' )
+	);
+
+	if ( ! isPanelExpanded ) {
+		// Click the Settings button within the Email panel
+		await page
+			.getByLabel( 'Email' )
+			.getByRole( 'button', { name: 'Settings' } )
+			.click();
+	}
+
+	// Click the "Woo Email Template" button to open dropdown
+	await page.getByRole( 'button', { name: 'Template actions' } ).click();
+
+	// Click "Edit template" in the dropdown
+	await page.getByRole( 'menuitem', { name: 'Edit template' } ).click();
+
+	// Click "Edit template" button in the modal
+	await page
+		.getByRole( 'button', { name: 'Edit template', exact: true } )
+		.click();
+
+	// Wait for template editor to load
+	await expect(
+		page.locator( 'iframe[name="editor-canvas"]' )
+	).toBeVisible();
+}
+
+test.describe( 'WooCommerce Email Editor Reset Template', () => {
+	test.use( { storageState: ADMIN_STATE_PATH } );
+
+	test.beforeAll( async ( { baseURL } ) => {
+		await enableEmailEditor( baseURL );
+	} );
+
+	test.afterAll( async ( { baseURL } ) => {
+		await resetWCTransactionalEmail( baseURL, 'customer_note' );
+		await disableEmailEditor( baseURL );
+	} );
+
+	test( 'Can reset a customized email template to default', async ( {
+		page,
+	} ) => {
+		// Access the email editor
+		await accessTheEmailEditor( page, 'Customer note' );
+
+		// Wait for the editor to load
+		await expect(
+			page.locator( '#woocommerce-email-editor' )
+		).toBeVisible();
+
+		// Switch to template editing mode
+		await switchToTemplateEditingMode( page );
+
+		// Make a customization - edit an existing paragraph block
+		const uniqueText = `CUSTOM TEXT ${ Date.now() }`;
+		const editorFrame = page
+			.frameLocator( 'iframe[name="editor-canvas"]' )
+			.first();
+
+		// Click on the last paragraph to select it (the footer paragraph is editable)
+		await editorFrame.locator( 'p' ).last().click();
+
+		// Clear existing text and type new text
+		await page.keyboard.press( 'ControlOrMeta+A' ); // Select all
+		await page.keyboard.type( uniqueText );
+
+		// Save the customization
+		await page.getByRole( 'button', { name: 'Save', exact: true } ).click();
+
+		// Wait for the save to complete
+		// eslint-disable-next-line playwright/no-wait-for-timeout -- wait for content to be saved
+		await page.waitForTimeout( 1000 );
+
+		// Verify the customization is present
+		await expect( editorFrame.getByText( uniqueText ) ).toBeVisible();
+
+		// Switch to Template tab to access the Actions button
+		await page.getByRole( 'tab', { name: 'Template' } ).click();
+
+		// Wait for Actions button to be enabled
+		await expect(
+			page.getByRole( 'button', { name: 'Actions' } )
+		).toBeEnabled();
+
+		// Open the actions dropdown (three dots menu)
+		await page.getByRole( 'button', { name: 'Actions' } ).click();
+
+		// Verify the Reset action is present
+		await expect(
+			page.getByRole( 'menuitem', { name: 'Reset' } )
+		).toBeVisible();
+
+		// Click the Reset action
+		await page.getByRole( 'menuitem', { name: 'Reset' } ).click();
+
+		// Verify the reset confirmation modal appears
+		await expect(
+			page.getByText( /Are you sure you want to reset.*to default\?/ )
+		).toBeVisible();
+
+		// Verify Cancel button exists
+		await expect(
+			page.getByRole( 'button', { name: 'Cancel' } )
+		).toBeVisible();
+
+		// Click the Reset button in the modal
+		await page
+			.getByRole( 'button', { name: 'Reset', exact: true } )
+			.click();
+
+		// Wait for the reset operation to complete
+		// eslint-disable-next-line playwright/no-wait-for-timeout -- wait for reset to complete
+		await page.waitForTimeout( 2000 );
+
+		// Verify the custom content is gone (template is reset)
+		const editorFrameAfterReset = page
+			.frameLocator( 'iframe[name="editor-canvas"]' )
+			.first();
+		await expect(
+			editorFrameAfterReset.getByText( uniqueText )
+		).toBeHidden();
+
+		// Verify Save button is disabled (no changes to save)
+		await expect(
+			page.getByRole( 'button', { name: 'Save', exact: true } )
+		).toBeDisabled();
+
+		// Verify Actions button is disabled (template is no longer custom)
+		await expect(
+			page.getByRole( 'button', { name: 'Actions' } )
+		).toBeDisabled();
+	} );
+} );