Commit 0484c5f5ed2 for woocommerce
commit 0484c5f5ed2e65f308deca1de3b327c2a2c4f537
Author: Yuliyan Slavchev <yuliyan.slavchev@gmail.com>
Date: Mon Mar 2 10:14:11 2026 +0200
Email Editor: Rework snackbar notices (#63451)
* Rework email editor notices overrides
* Remove EditorSnackbars in favor of SnackbarNotices
* Remove SnackbarNotices with `email-editor` context
* Add setting to disable snackbar notices
* Add changelog
* Switch to EditorSnackbars from @wordpress/editor for version compatiblity
* Add docs for email editor specific settings
* Use custom EditorSnackbars component for better compatibility
diff --git a/packages/js/email-editor/README.md b/packages/js/email-editor/README.md
index 2753a98f44a..a593bc8084d 100644
--- a/packages/js/email-editor/README.md
+++ b/packages/js/email-editor/README.md
@@ -18,7 +18,10 @@ window.WooCommerceEmailEditor = {
current_post_type: '', // The post type of the current post
current_post_id: '', // The ID of the current post
current_wp_user_email: '', // The email of the current user
- editor_settings: {}, // The block editor settings
+ editor_settings: {
+ // Standard block editor settings, plus email-editor-specific options.
+ // See the "Editor settings" section below for available options.
+ },
editor_theme: {}, // The block editor theme
user_theme_post_id: '', // The ID of the user theme post
urls: {
@@ -39,6 +42,16 @@ The `initializeEditor` function accepts a single parameter:
Make sure to set up the required data on `window.WooCommerceEmailEditor` before calling `initializeEditor`.
+### Editor settings
+
+The `editor_settings` object (or `config.editorSettings` when using `ExperimentalEmailEditor`) accepts all standard WordPress block editor settings plus the following email-editor-specific options:
+
+| Setting | Type | Default | Description |
+| ------------------------ | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `isFullScreenForced` | `boolean` | `false` | When `true`, the editor is always rendered in fullscreen mode and the user cannot toggle it off. The "More menu" is hidden and a back button is shown in the header. |
+| `displaySendEmailButton` | `boolean` | `false` | When `true`, a "Send" button is displayed in the editor header, allowing users to publish/send the email directly from the editor. |
+| `disableSnackbarNotices` | `boolean` | `false` | When `true`, the editor does not render its own snackbar notices. Pinned and validation notices are unaffected. |
+
## Exports
### Components
@@ -56,13 +69,18 @@ import { ExperimentalEmailEditor } from '@woocommerce/email-editor';
postId="123"
postType="email"
config={ {
- editorSettings: { /* ... */ },
- theme: { /* ... */ },
+ editorSettings: {
+ // Standard block editor settings, plus email-editor-specific
+ // options. See the "Editor settings" section for details.
+ },
+ theme: {
+ /* ... */
+ },
urls: { listings: '/emails', back: '/' },
userEmail: 'user@example.com',
globalStylesPostId: 456,
} }
-/>
+/>;
```
#### `SendPreviewEmail`
@@ -76,7 +94,7 @@ import { createStore, SendPreviewEmail } from '@woocommerce/email-editor';
createStore();
// ...
-<SendPreviewEmail />
+<SendPreviewEmail />;
```
#### `RichTextWithButton`
@@ -96,7 +114,7 @@ createStore();
attributeName="subject"
attributeValue={ currentSubject }
updateProperty={ ( name, value ) => setEmailProperty( name, value ) }
-/>
+/>;
```
### Hooks
@@ -166,7 +184,11 @@ createStore();
Analytics tracking utilities. Events are prefixed with `email_editor_events_` and only recorded when tracking is enabled. `recordEventOnce` deduplicates per session. `debouncedRecordEvent` waits 700ms to batch rapid actions.
```js
-import { recordEvent, recordEventOnce, debouncedRecordEvent } from '@woocommerce/email-editor';
+import {
+ recordEvent,
+ recordEventOnce,
+ debouncedRecordEvent,
+} from '@woocommerce/email-editor';
recordEvent( 'button_clicked', { buttonType: 'save' } );
recordEventOnce( 'editor_loaded' );
@@ -229,8 +251,8 @@ new DependencyExtractionWebpackPlugin( {
return null;
}
// ... handle other dependencies
- }
-} )
+ },
+} );
```
### Email Editor
diff --git a/packages/js/email-editor/changelog/wooprd-2541-double-snackbar b/packages/js/email-editor/changelog/wooprd-2541-double-snackbar
new file mode 100644
index 00000000000..3fc6ba16911
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooprd-2541-double-snackbar
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Rework snackbar notices by using the `EditorSnackbars` component from `@wordpress/editor` to render notices, removing the `email-editor` context snackbar notices renderer, and adding a `disableSnackbarNotices` setting to the email editor component
diff --git a/packages/js/email-editor/src/components/block-editor/editor.tsx b/packages/js/email-editor/src/components/block-editor/editor.tsx
index 434b362a023..fb0ae9d9893 100644
--- a/packages/js/email-editor/src/components/block-editor/editor.tsx
+++ b/packages/js/email-editor/src/components/block-editor/editor.tsx
@@ -107,7 +107,11 @@ export function InnerEditor( {
};
}, [] );
- const { isFullScreenForced, displaySendEmailButton } = settings;
+ const {
+ isFullScreenForced,
+ displaySendEmailButton,
+ disableSnackbarNotices,
+ } = settings;
// @ts-expect-error Type is missing in @types/wordpress__editor
const { removeEditorPanel } = useDispatch( editorStore );
@@ -192,7 +196,11 @@ export function InnerEditor( {
<SettingsPanel />
) }
{ displaySendEmailButton && <PublishSave /> }
- <EditorNotices />
+ <EditorNotices
+ disableSnackbarNotices={
+ disableSnackbarNotices as boolean | undefined
+ }
+ />
<BlockCompatibilityWarnings />
<PluginArea scope="woocommerce-email-editor" />
</Editor>
diff --git a/packages/js/email-editor/src/components/notices/index.ts b/packages/js/email-editor/src/components/notices/index.ts
index 5fb4d392add..769aec2deae 100644
--- a/packages/js/email-editor/src/components/notices/index.ts
+++ b/packages/js/email-editor/src/components/notices/index.ts
@@ -1,3 +1,2 @@
export { EditorNotices } from './notices';
-export { EditorSnackbars } from './snackbars';
export { SentEmailNotice } from './sent-email-notice';
diff --git a/packages/js/email-editor/src/components/notices/notices.tsx b/packages/js/email-editor/src/components/notices/notices.tsx
index 818425ef27b..9af5ec33f23 100644
--- a/packages/js/email-editor/src/components/notices/notices.tsx
+++ b/packages/js/email-editor/src/components/notices/notices.tsx
@@ -14,7 +14,13 @@ import { NoticesSlot } from '../../hacks/notices-slot';
// See: https://github.com/WordPress/gutenberg/blob/5be0ec4153c3adf9f0f2513239f4f7a358ba7948/packages/editor/src/components/editor-notices/index.js
-export function EditorNotices() {
+interface EditorNoticesProps {
+ disableSnackbarNotices?: boolean;
+}
+
+export function EditorNotices( {
+ disableSnackbarNotices = false,
+}: EditorNoticesProps = {} ) {
const { notices } = useSelect(
( select ) => ( {
notices: select( noticesStore ).getNotices( 'email-editor' ),
@@ -46,8 +52,7 @@ export function EditorNotices() {
/>
<ValidationNotices />
</NoticesSlot>
- <EditorSnackbars context="global" />
- <EditorSnackbars context="email-editor" />
+ { ! disableSnackbarNotices && <EditorSnackbars /> }
</>
);
}
diff --git a/packages/js/email-editor/src/components/notices/snackbars.tsx b/packages/js/email-editor/src/components/notices/snackbars.tsx
index 5ea053fe0f9..5b84c2ded00 100644
--- a/packages/js/email-editor/src/components/notices/snackbars.tsx
+++ b/packages/js/email-editor/src/components/notices/snackbars.tsx
@@ -1,70 +1,32 @@
/**
* External dependencies
*/
-import { useMemo } from '@wordpress/element';
import { SnackbarList } from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
-// See: https://github.com/WordPress/gutenberg/blob/2788a9cf8b8149be3ee52dd15ce91fa55815f36a/packages/editor/src/components/editor-snackbars/index.js
+// Based on https://github.com/WordPress/gutenberg/blob/2788a9cf8b8149be3ee52dd15ce91fa55815f36a/packages/editor/src/components/editor-snackbars/index.js
+// Uses both class names to support pre-7.0 and 7.0+ Gutenberg layouts.
-export function EditorSnackbars( { context = 'email-editor' } ) {
- const { notices } = useSelect(
- ( select ) => ( {
- notices: select( noticesStore ).getNotices( context ),
- } ),
- [ context ]
- );
+const MAX_VISIBLE_NOTICES = -3;
- // Some global notices are not suitable for the email editor context
- // This map allows us to change the content of the notice
- const globalNoticeChangeMap = useMemo( () => {
- return {
- 'site-editor-save-success': {
- content: __( 'Email design updated.', 'woocommerce' ),
- removeActions: true,
- },
- 'editor-save': {
- content: __( 'Email saved.', 'woocommerce' ),
- removeActions: false,
- contentCheck: ( notice ) => {
- // eslint-disable-next-line @wordpress/i18n-text-domain
- return notice.content.includes( __( 'Post updated.' ) ); // It is intentionally without domain to match core translation
- },
- },
- };
- }, [] );
+export function EditorSnackbars() {
+ const notices = useSelect(
+ ( select ) => select( noticesStore ).getNotices(),
+ []
+ );
const { removeNotice } = useDispatch( noticesStore );
const snackbarNotices = notices
.filter( ( { type } ) => type === 'snackbar' )
- .map( ( notice ) => {
- if ( ! globalNoticeChangeMap[ notice.id ] ) {
- return notice;
- }
- if (
- globalNoticeChangeMap[ notice.id ].contentCheck &&
- ! globalNoticeChangeMap[ notice.id ].contentCheck( notice )
- ) {
- return notice;
- }
- return {
- ...notice,
- content: globalNoticeChangeMap[ notice.id ].content,
- spokenMessage: globalNoticeChangeMap[ notice.id ].content,
- actions: globalNoticeChangeMap[ notice.id ].removeActions
- ? []
- : notice.actions,
- };
- } );
+ .slice( MAX_VISIBLE_NOTICES );
return (
<SnackbarList
notices={ snackbarNotices }
- className="components-editor-notices__snackbar"
- onRemove={ ( id ) => removeNotice( id, context ) }
+ className="components-editor-notices__snackbar edit-post-layout__snackbar"
+ onRemove={ removeNotice }
/>
);
}
diff --git a/packages/js/email-editor/src/editor.tsx b/packages/js/email-editor/src/editor.tsx
index 04bcd506f36..b8c801292f9 100644
--- a/packages/js/email-editor/src/editor.tsx
+++ b/packages/js/email-editor/src/editor.tsx
@@ -31,8 +31,9 @@ import { initContentValidationMiddleware } from './middleware/content-validation
import { initHacks } from './hacks';
import {
useContentValidation,
- useRemoveSavingFailedNotices,
useFilterEditorContentStylesheets,
+ useNoticeOverrides,
+ useRemoveSavingFailedNotices,
} from './hooks';
import { cleanupConfigurationChanges } from './config-tools';
import { getEditorConfigFromWindow } from './store/settings';
@@ -61,6 +62,7 @@ function Editor( {
useContentValidation();
useRemoveSavingFailedNotices();
+ useNoticeOverrides();
const { setEmailPost } = useDispatch( storeName );
useEffect( () => {
diff --git a/packages/js/email-editor/src/hooks/index.ts b/packages/js/email-editor/src/hooks/index.ts
index 730d9e3e908..ac636a4be26 100644
--- a/packages/js/email-editor/src/hooks/index.ts
+++ b/packages/js/email-editor/src/hooks/index.ts
@@ -8,3 +8,4 @@ export * from './use-editor-mode';
export * from './use-remove-saving-failed-notices';
export * from './use-is-email-editor';
export * from './use-filter-editor-content-stylesheets';
+export * from './use-notice-overrides';
diff --git a/packages/js/email-editor/src/hooks/use-notice-overrides.ts b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
new file mode 100644
index 00000000000..2088149c652
--- /dev/null
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -0,0 +1,122 @@
+/**
+ * External dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { createSelector, use } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+
+/**
+ * Wraps the `getNotices` selector on the notices store so that specific
+ * notices are returned with email-editor-appropriate content.
+ *
+ * Use the `useNoticeOverrides` hook when mounting the email editor. On
+ * mount it applies a data plugin that overrides the selector; on unmount
+ * it applies a plugin that restores the original select.
+ */
+
+interface NoticeOverride {
+ content: string;
+ removeActions: boolean;
+ contentCheck?: ( content: string ) => boolean;
+}
+
+function getNoticeOverrides(): Record< string, NoticeOverride > {
+ return {
+ 'site-editor-save-success': {
+ content: __( 'Email design updated.', 'woocommerce' ),
+ removeActions: true,
+ },
+ 'editor-save': {
+ content: __( 'Email saved.', 'woocommerce' ),
+ removeActions: false,
+ contentCheck: ( content: string ) =>
+ // Intentionally without text domain to match the core translation.
+ // eslint-disable-next-line @wordpress/i18n-text-domain
+ content.includes( __( 'Post updated.' ) ),
+ },
+ };
+}
+
+interface Notice {
+ id: string;
+ content: string;
+ spokenMessage: string;
+ actions: unknown[];
+ [ key: string ]: unknown;
+}
+
+function transformNotice( notice: Notice ): Notice {
+ const overrides = getNoticeOverrides();
+ const override = overrides[ notice.id ];
+ if ( ! override ) {
+ return notice;
+ }
+ if ( override.contentCheck && ! override.contentCheck( notice.content ) ) {
+ return notice;
+ }
+ return {
+ ...notice,
+ content: override.content,
+ spokenMessage: override.content,
+ actions: override.removeActions ? [] : notice.actions,
+ };
+}
+
+function applyOverridesToNotices( notices: Notice[] ): Notice[] {
+ return notices.map( ( notice ) => transformNotice( notice ) );
+}
+
+function getStoreName( namespace: string | { name: string } ): string {
+ return typeof namespace === 'object' ? namespace.name : namespace;
+}
+
+/**
+ * Applies notice overrides when the email editor is mounted and restores
+ * the original select when it unmounts.
+ */
+export function useNoticeOverrides(): void {
+ useEffect( () => {
+ let originalSelect: ( namespace: string | { name: string } ) => unknown;
+
+ use( ( registry: { select: ( ...args: unknown[] ) => unknown } ) => {
+ originalSelect = registry.select;
+
+ return {
+ select: ( namespace: string | { name: string } ) => {
+ if ( getStoreName( namespace ) !== noticesStore.name ) {
+ return originalSelect( namespace );
+ }
+
+ const selectors = originalSelect( namespace ) as {
+ getNotices?: ( context?: string ) => Notice[];
+ [ key: string ]: unknown;
+ };
+
+ const originalGetNotices = selectors.getNotices;
+ if ( ! originalGetNotices ) {
+ return selectors;
+ }
+
+ const getNoticesWithOverrides = createSelector(
+ ( notices: Notice[] ) =>
+ applyOverridesToNotices( notices ),
+ ( notices: Notice[] ) => [ notices ]
+ );
+
+ return {
+ ...selectors,
+ getNotices: ( context?: string ) =>
+ getNoticesWithOverrides(
+ originalGetNotices( context )
+ ),
+ };
+ },
+ };
+ } );
+
+ return () => {
+ use( () => ( { select: originalSelect } ) );
+ };
+ }, [] );
+}