Commit 6befb768a30 for woocommerce
commit 6befb768a30918ce953a5e2f6e0c83542ff5b0bf
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Thu Sep 24 07:24:47 2026 +0200
Drop the view-post link from the email editor save notice (#68944)
* Drop the view-post link from the email save notice
Saving an email in the block editor showed a snackbar with a link to
the post permalink, labelled with the post type's view_item label —
"View Email", or "View Post" in integrations that do not set their own.
An email is not a web page, and the editor header already offers a
preview, so the link was both wrong and redundant.
The override table already supported removing a notice's actions, but
that only ran when the notice text matched the rewrite condition.
Action removal now happens whenever the notice id matches, so the link
is gone for every save variant — published, updated, and draft — and in
every locale, regardless of whether the text rewrite applies.
* Match the email save notice against the post type labels
The override that rewrites the save snackbar to "Email saved." only
fired when the notice text matched the English literals "Post updated."
and "Post published.". Those strings never reach the browser as English
on a translated site: WordPress builds the message from the post type's
item_updated and item_published labels, which PHP translates on the
server. The literals themselves appear in no JavaScript source, so they
are in no JavaScript translation catalogue and __() returns them
unchanged. The comparison could therefore only succeed on an
English-language store, leaving every other site with the raw
"Post updated." wording.
The override now compares the notice against the post type's own
labels, read from the core data store, so it matches whatever wording
WordPress produced. The label set also covers the privately published
and scheduled variants, which the literal check never reached. When the
labels are not loaded the text is left alone and only the link is
removed.
* Add changelog entries for the email editor save notice fixes
* Only read the post type when a notice is overridable
Reading the post type labels on every getNotices call made each caller
register the core data store as a listened store, so the notice
components re-ran their selectors on every entity edit — that is, on
every keystroke in the editor. The lookup now happens only when the
notices actually contain an id the hook overrides.
The core store lookup is also optional-chained, matching the guard on
the sibling call. The hook replaces select for the whole registry, so
an unresolvable core store would have thrown on every getNotices call
in the application, not only in the email editor.
The store name now comes from the constants module rather than the
store barrel, which registers the store as a side effect and pulled
@wordpress/components into a leaf hook. The spec no longer has to mock
the whole module to load.
Labels fall back to a shared empty object so the memoized selector's
dependants stay object-like, and new tests cover the unresolved post
type, the unregistered stores, and that a label is matched exactly
rather than as a substring.
* Keep the template editor's own save wording
The email template editor reports its post type as wp_template, whose
"Template updated." label matched the new label check and was rewritten
to "Email saved.". That wording belongs to emails, not to designs, and
the package already has its own string for design saves. The rewrite
now skips template post types and leaves WordPress's wording alone. The
link is still removed there, as it is for every save notice.
Also drops the empty-labels fallback added with the previous commit.
Its comment claimed rememo abandons its per-dependant cache when a
dependant is not object-like, which is not what rememo does: it stops
at the first such dependant and keeps the levels above it, so the
notices array still gets its own cache entry. The test written to pin
the fallback passed with the fallback removed, so it went too.
* Narrow the save notice rewrite to the reachable labels
Two corrections to the override table, both from review.
The scheduled and privately-published labels were listed among the
texts the hook rewrites, so a scheduled email would have reported
"Email saved." and lost the confirmation that it is queued to send.
Neither status is reachable: the editor removes the post-status panel
on mount, leaving no way to set a future date or private visibility, so
both labels are dropped as configuration that can never match.
The override table was also read with a plain property lookup, so a
notice whose id happened to be an Object.prototype member name, such as
constructor or toString, resolved to an inherited function rather than
to nothing. The hook then treated it as overridable and blanked its
text. Both lookups now test for an own property.
* Use the design wording when saving an email template
Saving the email design reported two different things depending on what
had changed. Editing the styles marks the global styles entity dirty, so
the save goes through the multi-entity path and reports "Email design
updated.". Saving the template content goes through the ordinary post
save instead, and its notice is built from the template post type's
labels, so it read "Template updated." — WordPress's generic wording, in
the middle of the email editor.
Template post types now get that same "Email design updated." wording
rather than being left alone. The earlier reason for skipping them
stands: a design save must not claim the email itself was saved. It
needed the right message, though, not no message. Both overrides read
from one constant so the two paths cannot drift apart.
* Record why the trash and revert labels are not matched
The override rewrites a save notice by comparing it against the post
type's labels, and a reader looking at that list will wonder why the
trashed and reverted-to-draft labels are missing, since both would
otherwise show WordPress's generic post wording in the email editor.
Neither can reach an editor-save notice. Trashing an email runs through
a custom action that deletes the record and raises its own notice, and
there is no way to move a published email back to draft once the
post-status panel is removed. Note it so the next reader does not add
wording for states nobody can get into.
diff --git a/packages/js/email-editor/changelog/stomail-8517-localized-save-notice b/packages/js/email-editor/changelog/stomail-8517-localized-save-notice
new file mode 100644
index 00000000000..624be8ef360
--- /dev/null
+++ b/packages/js/email-editor/changelog/stomail-8517-localized-save-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the email editor save notice in the site language instead of the untranslated post wording
diff --git a/packages/js/email-editor/changelog/stomail-8517-remove-save-notice-link b/packages/js/email-editor/changelog/stomail-8517-remove-save-notice-link
new file mode 100644
index 00000000000..684c8ab6b75
--- /dev/null
+++ b/packages/js/email-editor/changelog/stomail-8517-remove-save-notice-link
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove the view-post link from the notice shown after saving an email in the email editor
diff --git a/packages/js/email-editor/changelog/stomail-8517-template-save-wording b/packages/js/email-editor/changelog/stomail-8517-template-save-wording
new file mode 100644
index 00000000000..3201cd3a5e1
--- /dev/null
+++ b/packages/js/email-editor/changelog/stomail-8517-template-save-wording
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Report the same wording when saving the email design from either the editor or the template itself
diff --git a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
index de7619c0a10..f4eac360601 100644
--- a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
+++ b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
@@ -7,6 +7,7 @@ import { renderHook } from '@testing-library/react';
* Internal dependencies
*/
import { useNoticeOverrides } from '../use-notice-overrides';
+import { storeName as EMAIL_EDITOR_STORE_NAME } from '../../store/constants';
// Keep a reference to the plugin callback registered via `use()`.
let capturedPlugin: ( registry: {
@@ -38,6 +39,10 @@ jest.mock( '@wordpress/notices', () => ( {
store: { name: 'core/notices' },
} ) );
+jest.mock( '@wordpress/core-data', () => ( {
+ store: { name: 'core' },
+} ) );
+
jest.mock( '@wordpress/i18n', () => ( {
__: ( text: string ) => text,
} ) );
@@ -49,6 +54,15 @@ interface Notice {
actions: unknown[];
}
+type Labels = Record< string, string > | undefined;
+
+// `originalSelect` is called with either a store name string (from the
+// plugin's own `select( namespace )`) or a store descriptor object (from
+// the hook's own `originalSelect( coreStore )` / `originalSelect( storeName )`
+// calls) — normalise both to a name for routing in the test doubles below.
+const resolveStoreName = ( ns: string | { name: string } ): string =>
+ typeof ns === 'object' ? ns.name : ns;
+
const makeNotice = ( partial: Partial< Notice > = {} ): Notice => ( {
id: 'test-notice',
content: 'Test notice',
@@ -62,21 +76,68 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
jest.clearAllMocks();
} );
- function buildSelectOverride( notices: Notice[] ) {
+ /**
+ * Builds the `originalSelect` stub the plugin wraps, routing
+ * `core/notices`, `core`, and the email editor store to separate
+ * selector objects so the hook's cross-store label lookup can be
+ * exercised.
+ *
+ * @param notices Notices `core/notices`' `getNotices` should return.
+ * @param labels Labels `core`'s `getPostType` should return, keyed by
+ * post type. `undefined` means the post type isn't
+ * loaded yet.
+ * @param postType Post type the email editor store's
+ * `getEmailPostType` should return.
+ */
+ function buildSelectOverride(
+ notices: Notice[],
+ labels?: Labels,
+ postType = 'email'
+ ) {
const originalGetNotices = jest.fn().mockReturnValue( notices );
- const originalSelectors = { getNotices: originalGetNotices };
+ const noticesSelectors = { getNotices: originalGetNotices };
- const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+ const getPostType = jest
+ .fn()
+ .mockReturnValue( labels === undefined ? undefined : { labels } );
+ const coreSelectors = { getPostType };
+
+ const getEmailPostType = jest.fn().mockReturnValue( postType );
+ const emailEditorSelectors = { getEmailPostType };
+
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === 'core' ) {
+ return coreSelectors;
+ }
+ if ( name === EMAIL_EDITOR_STORE_NAME ) {
+ return emailEditorSelectors;
+ }
+ return undefined;
+ } );
renderHook( () => useNoticeOverrides() );
const pluginResult = capturedPlugin( { select: originalSelect } );
- return { pluginResult, originalSelect, originalGetNotices };
+ return {
+ pluginResult,
+ originalSelect,
+ originalGetNotices,
+ getPostType,
+ getEmailPostType,
+ };
}
- it( 'getNotices returns the same array reference when notices input is unchanged', () => {
+ it( 'getNotices returns the same array reference when notices and labels are unchanged', () => {
const notices = [ makeNotice() ];
- const { pluginResult } = buildSelectOverride( notices );
+ const { pluginResult } = buildSelectOverride( notices, {
+ item_updated: 'Post updated.',
+ } );
const selectors = pluginResult.select( 'core/notices' ) as {
getNotices: () => Notice[];
@@ -90,8 +151,30 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
it( 'getNotices returns a new array reference when notices input changes', () => {
const originalGetNotices = jest.fn();
- const originalSelectors = { getNotices: originalGetNotices };
- const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+ const noticesSelectors = { getNotices: originalGetNotices };
+ const coreSelectors = {
+ getPostType: jest.fn().mockReturnValue( {
+ labels: { item_updated: 'Post updated.' },
+ } ),
+ };
+ const emailEditorSelectors = {
+ getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+ };
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === 'core' ) {
+ return coreSelectors;
+ }
+ if ( name === EMAIL_EDITOR_STORE_NAME ) {
+ return emailEditorSelectors;
+ }
+ return undefined;
+ } );
renderHook( () => useNoticeOverrides() );
const pluginResult = capturedPlugin( { select: originalSelect } );
@@ -110,6 +193,52 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
expect( firstResult ).not.toBe( secondResult );
} );
+ it( 'getNotices returns a new array reference when the labels reference changes', () => {
+ // Uses an overridable notice id: the postType/labels lookup (finding 3)
+ // is skipped entirely for non-overridable notices, which would make
+ // this label change invisible to the memoized selector.
+ const notices = [ makeNotice( { id: 'editor-save' } ) ];
+ const originalGetNotices = jest.fn().mockReturnValue( notices );
+ const noticesSelectors = { getNotices: originalGetNotices };
+ const getPostType = jest
+ .fn()
+ .mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+ const coreSelectors = { getPostType };
+ const emailEditorSelectors = {
+ getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+ };
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === 'core' ) {
+ return coreSelectors;
+ }
+ if ( name === EMAIL_EDITOR_STORE_NAME ) {
+ return emailEditorSelectors;
+ }
+ return undefined;
+ } );
+
+ renderHook( () => useNoticeOverrides() );
+ const pluginResult = capturedPlugin( { select: originalSelect } );
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+
+ const firstResult = selectors.getNotices();
+
+ getPostType.mockReturnValue( {
+ labels: { item_updated: 'Příspěvek byl aktualizován.' },
+ } );
+ const secondResult = selectors.getNotices();
+
+ expect( firstResult ).not.toBe( secondResult );
+ } );
+
it( 'passes through select for non-notices stores unchanged', () => {
const notices = [ makeNotice() ];
const otherSelectors = { getSomething: jest.fn() };
@@ -129,12 +258,16 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
expect( result ).toBe( otherSelectors );
} );
- it( 'transforms known notice content via getNotices', () => {
+ it( 'transforms an editor-save notice whose content matches the localized item_updated label', () => {
const originalNotice = makeNotice( {
id: 'editor-save',
- content: 'Post updated.',
+ content: 'Příspěvek byl aktualizován.',
+ actions: [ { label: 'Zobrazit e-mail', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ item_updated: 'Příspěvek byl aktualizován.',
+ view_item: 'Zobrazit e-mail',
} );
- const { pluginResult } = buildSelectOverride( [ originalNotice ] );
const selectors = pluginResult.select( 'core/notices' ) as {
getNotices: () => Notice[];
@@ -142,16 +275,19 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
const result = selectors.getNotices();
expect( result[ 0 ].content ).toBe( 'Email saved.' );
+ expect( result[ 0 ].spokenMessage ).toBe( 'Email saved.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
} );
- it( 'transforms an editor-save notice with "Post published." content', () => {
- // Emitted when an integration's save button publishes the post in the
- // background (lazy post creation) instead of a plain update.
+ it( 'transforms an editor-save notice matching a custom integration item_updated label', () => {
const originalNotice = makeNotice( {
id: 'editor-save',
- content: 'Post published.',
+ content: 'Email updated.',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ item_updated: 'Email updated.',
} );
- const { pluginResult } = buildSelectOverride( [ originalNotice ] );
const selectors = pluginResult.select( 'core/notices' ) as {
getNotices: () => Notice[];
@@ -161,45 +297,124 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
expect( result[ 0 ].content ).toBe( 'Email saved.' );
} );
- it( 'leaves an editor-save notice with "Draft saved." content unchanged', () => {
- // A saved draft is not used for sending; rewriting the notice to
- // "Email saved." would suggest the opposite.
+ it.each( [ [ 'item_published', 'Příspěvek byl publikován.' ] ] )(
+ 'transforms an editor-save notice matching the localized %s label',
+ ( labelKey, localizedText ) => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: localizedText,
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ [ labelKey ]: localizedText,
+ } );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Email saved.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ }
+ );
+
+ it( 'leaves an editor-save notice unchanged when labels are not loaded yet, but still removes the action', () => {
const originalNotice = makeNotice( {
id: 'editor-save',
- content: 'Draft saved.',
+ content: 'Post updated.',
+ actions: [ { label: 'View Email', url: '#' } ],
} );
- const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+ const { pluginResult } = buildSelectOverride(
+ [ originalNotice ],
+ undefined
+ );
const selectors = pluginResult.select( 'core/notices' ) as {
getNotices: () => Notice[];
};
const result = selectors.getNotices();
- expect( result[ 0 ].content ).toBe( 'Draft saved.' );
+ expect( result[ 0 ].content ).toBe( 'Post updated.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
} );
- it( 'leaves an editor-save notice with unrelated content unchanged', () => {
+ it( 'leaves an editor-save notice with content matching no label unchanged', () => {
const originalNotice = makeNotice( {
id: 'editor-save',
- content: 'Saving failed.',
+ content: 'Updating failed.',
+ actions: [],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ item_updated: 'Post updated.',
} );
- const { pluginResult } = buildSelectOverride( [ originalNotice ] );
const selectors = pluginResult.select( 'core/notices' ) as {
getNotices: () => Notice[];
};
const result = selectors.getNotices();
- expect( result[ 0 ].content ).toBe( 'Saving failed.' );
+ expect( result[ 0 ].content ).toBe( 'Updating failed.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
} );
- it( 'transforms site-editor-save-success notice and removes actions', () => {
+ it.each( [
+ [ 'item_published_privately', 'Příspěvek byl publikován soukromě.' ],
+ [ 'item_scheduled', 'Příspěvek byl naplánován.' ],
+ ] )(
+ 'leaves an editor-save notice matching the localized %s label unchanged, since the editor has no UI to reach that status',
+ ( labelKey, localizedText ) => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: localizedText,
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ [ labelKey ]: localizedText,
+ } );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( localizedText );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ }
+ );
+
+ it( 'leaves an editor-save notice with "Draft saved." content unchanged but removes the action', () => {
+ // A saved draft is not used for sending; rewriting the notice to
+ // "Email saved." would suggest the opposite. The view-post action is
+ // still dropped, since it points at a permalink that is not the email.
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Draft saved.',
+ actions: [ { label: 'View Preview', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ item_updated: 'Post updated.',
+ } );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Draft saved.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ } );
+
+ it( 'transforms site-editor-save-success notice and removes actions regardless of labels', () => {
const originalNotice = makeNotice( {
id: 'site-editor-save-success',
content: 'Site updated.',
actions: [ { label: 'View', url: '#' } ],
} );
- const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+ const { pluginResult } = buildSelectOverride(
+ [ originalNotice ],
+ undefined
+ );
const selectors = pluginResult.select( 'core/notices' ) as {
getNotices: () => Notice[];
@@ -220,4 +435,282 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
const result = pluginResult.select( 'core/notices' );
expect( result ).toBe( originalSelectors );
} );
+
+ it( 'does not rewrite content that merely contains the label as a substring', () => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Post updated. Extra',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ item_updated: 'Post updated.',
+ } );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Post updated. Extra' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ } );
+
+ it( 'skips the postType/labels lookup when the email post type is not set yet, but still removes the action', () => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Post updated.',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const originalGetNotices = jest
+ .fn()
+ .mockReturnValue( [ originalNotice ] );
+ const noticesSelectors = { getNotices: originalGetNotices };
+ const getPostType = jest
+ .fn()
+ .mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+ const coreSelectors = { getPostType };
+ const getEmailPostType = jest.fn().mockReturnValue( undefined );
+ const emailEditorSelectors = { getEmailPostType };
+
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === 'core' ) {
+ return coreSelectors;
+ }
+ if ( name === EMAIL_EDITOR_STORE_NAME ) {
+ return emailEditorSelectors;
+ }
+ return undefined;
+ } );
+
+ renderHook( () => useNoticeOverrides() );
+ const pluginResult = capturedPlugin( { select: originalSelect } );
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Post updated.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ expect( getPostType ).not.toHaveBeenCalled();
+ } );
+
+ it( 'leaves notices unchanged without throwing when the email editor store is not registered', () => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Post updated.',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const originalGetNotices = jest
+ .fn()
+ .mockReturnValue( [ originalNotice ] );
+ const noticesSelectors = { getNotices: originalGetNotices };
+ const coreSelectors = {
+ getPostType: jest.fn().mockReturnValue( {
+ labels: { item_updated: 'Post updated.' },
+ } ),
+ };
+
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === 'core' ) {
+ return coreSelectors;
+ }
+ // Email editor store not registered.
+ return undefined;
+ } );
+
+ renderHook( () => useNoticeOverrides() );
+ const pluginResult = capturedPlugin( { select: originalSelect } );
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+
+ expect( () => selectors.getNotices() ).not.toThrow();
+ const result = selectors.getNotices();
+ expect( result[ 0 ].content ).toBe( 'Post updated.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ } );
+
+ it( 'leaves notices unchanged without throwing when the core store selectors are unavailable', () => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Post updated.',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const originalGetNotices = jest
+ .fn()
+ .mockReturnValue( [ originalNotice ] );
+ const noticesSelectors = { getNotices: originalGetNotices };
+ const emailEditorSelectors = {
+ getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+ };
+
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === EMAIL_EDITOR_STORE_NAME ) {
+ return emailEditorSelectors;
+ }
+ // Core store not resolvable in this registry.
+ return undefined;
+ } );
+
+ renderHook( () => useNoticeOverrides() );
+ const pluginResult = capturedPlugin( { select: originalSelect } );
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+
+ expect( () => selectors.getNotices() ).not.toThrow();
+ const result = selectors.getNotices();
+ expect( result[ 0 ].content ).toBe( 'Post updated.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ } );
+
+ it( 'skips the postType/labels lookup entirely when no notice is overridable', () => {
+ const originalNotice = makeNotice( {
+ id: 'unrelated-notice',
+ content: 'Something else',
+ } );
+ const originalGetNotices = jest
+ .fn()
+ .mockReturnValue( [ originalNotice ] );
+ const noticesSelectors = { getNotices: originalGetNotices };
+ const getEmailPostType = jest.fn().mockReturnValue( 'email' );
+ const emailEditorSelectors = { getEmailPostType };
+ const getPostType = jest
+ .fn()
+ .mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+ const coreSelectors = { getPostType };
+
+ const originalSelect = jest
+ .fn()
+ .mockImplementation( ( ns: string | { name: string } ) => {
+ const name = resolveStoreName( ns );
+ if ( name === 'core/notices' ) {
+ return noticesSelectors;
+ }
+ if ( name === 'core' ) {
+ return coreSelectors;
+ }
+ if ( name === EMAIL_EDITOR_STORE_NAME ) {
+ return emailEditorSelectors;
+ }
+ return undefined;
+ } );
+
+ renderHook( () => useNoticeOverrides() );
+ const pluginResult = capturedPlugin( { select: originalSelect } );
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ] ).toBe( originalNotice );
+ expect( getEmailPostType ).not.toHaveBeenCalled();
+ expect( getPostType ).not.toHaveBeenCalled();
+ } );
+
+ it( 'leaves a notice whose id collides with an Object.prototype member completely untouched', () => {
+ const originalNotice = makeNotice( {
+ id: 'constructor',
+ content: 'Something else',
+ spokenMessage: 'Something else',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+ item_updated: 'Post updated.',
+ } );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Something else' );
+ expect( result[ 0 ].spokenMessage ).toBe( 'Something else' );
+ expect( result[ 0 ].actions ).toEqual( originalNotice.actions );
+ } );
+
+ it.each( [ 'wp_template', 'wp_template_part' ] )(
+ 'transforms an editor-save notice matching item_updated for the %s post type to "Email design updated."',
+ ( postType ) => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Template updated.',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride(
+ [ originalNotice ],
+ { item_updated: 'Template updated.' },
+ postType
+ );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Email design updated.' );
+ expect( result[ 0 ].spokenMessage ).toBe( 'Email design updated.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ }
+ );
+
+ it( 'transforms an editor-save notice matching item_published for the wp_template post type to "Email design updated."', () => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Post published.',
+ actions: [ { label: 'View', url: '#' } ],
+ } );
+ const { pluginResult } = buildSelectOverride(
+ [ originalNotice ],
+ { item_published: 'Post published.' },
+ 'wp_template'
+ );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Email design updated.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ } );
+
+ it( 'leaves an editor-save notice with content matching no label unchanged for a template post type', () => {
+ const originalNotice = makeNotice( {
+ id: 'editor-save',
+ content: 'Updating failed.',
+ actions: [],
+ } );
+ const { pluginResult } = buildSelectOverride(
+ [ originalNotice ],
+ { item_updated: 'Template updated.' },
+ 'wp_template'
+ );
+
+ const selectors = pluginResult.select( 'core/notices' ) as {
+ getNotices: () => Notice[];
+ };
+ const result = selectors.getNotices();
+
+ expect( result[ 0 ].content ).toBe( 'Updating failed.' );
+ expect( result[ 0 ].actions ).toEqual( [] );
+ } );
} );
diff --git a/packages/js/email-editor/src/hooks/use-notice-overrides.ts b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
index 5b92698fd10..37ff9464ada 100644
--- a/packages/js/email-editor/src/hooks/use-notice-overrides.ts
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -5,6 +5,12 @@ import { useEffect } from '@wordpress/element';
import { createSelector, use } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { storeName } from '../store/constants';
/**
* Wraps the `getNotices` selector on the notices store so that specific
@@ -18,24 +24,51 @@ import { store as noticesStore } from '@wordpress/notices';
interface NoticeOverride {
content: string;
removeActions: boolean;
- contentCheck?: ( content: string ) => boolean;
+ labelKeys?: string[];
}
+// Shared with the `editor-save` override below so a template design save
+// and an in-editor design save report the exact same wording.
+const EMAIL_DESIGN_UPDATED_MESSAGE = __(
+ 'Email design updated.',
+ __i18n_text_domain__
+);
+
function getNoticeOverrides(): Record< string, NoticeOverride > {
return {
'site-editor-save-success': {
- content: __( 'Email design updated.', __i18n_text_domain__ ),
+ content: EMAIL_DESIGN_UPDATED_MESSAGE,
removeActions: true,
},
'editor-save': {
content: __( 'Email saved.', __i18n_text_domain__ ),
- removeActions: false,
- // "Draft saved." is intentionally NOT rewritten: a saved draft is
- // not used for sending, and "Email saved." would suggest it is.
- contentCheck: ( content: string ) =>
- // Intentionally without text domain to match the core translations.
- content.includes( __( 'Post updated.' ) ) ||
- content.includes( __( 'Post published.' ) ),
+ // Gutenberg attaches an action linking to the post permalink,
+ // labelled with the post type's `view_item` label, which reads
+ // as "View Post"/"View Email" for an email. Drop it: a preview
+ // is already available from the editor header.
+ removeActions: true,
+ // The notice text is rewritten only when it equals one of the
+ // post type's success labels, which WordPress translates on the
+ // server (so this works in any site locale). "Draft saved." is
+ // deliberately left as-is: a saved draft is not used for
+ // sending, and "Email saved." would suggest it is.
+ // `item_published_privately` and `item_scheduled` are omitted:
+ // the editor removes the "post-status" panel on mount (see
+ // `block-editor/editor.tsx`), so there is no UI to set an email
+ // post's visibility to private or its status to `future` — those
+ // labels can never match a real notice here.
+ // `item_trashed` and `item_reverted_to_draft` are omitted too:
+ // Gutenberg only puts them on an `editor-save` notice by way of
+ // its own `trashPost()` and status-change flows, both of which
+ // end in a `dispatch.savePost()` call. The editor doesn't use
+ // either — trashing an email goes through a custom action that
+ // calls `deleteEntityRecord()` directly and reports its own
+ // `trash-email-post-action` notice (see
+ // `components/header/trash-email-post.tsx`), and there is no UI
+ // to revert a published post to draft once the "post-status"
+ // panel is removed above. So these labels can never match a real
+ // `editor-save` notice either.
+ labelKeys: [ 'item_updated', 'item_published' ],
},
};
}
@@ -48,25 +81,57 @@ interface Notice {
[ key: string ]: unknown;
}
-function transformNotice( notice: Notice ): Notice {
+type PostTypeLabels = Record< string, string > | undefined;
+
+// Post types whose own save notices ("Template updated.", …) get their own
+// "Email design updated." wording instead of "Email saved." — that text is
+// reserved for actual email post types, since saving a template is a design
+// change, not a change to the email content itself.
+const TEMPLATE_POST_TYPES = [ 'wp_template', 'wp_template_part' ];
+
+function isTemplatePostType( postType: string | undefined ): boolean {
+ return !! postType && TEMPLATE_POST_TYPES.includes( postType );
+}
+
+function transformNotice(
+ notice: Notice,
+ labels: PostTypeLabels,
+ postType: string | undefined
+): Notice {
const overrides = getNoticeOverrides();
- const override = overrides[ notice.id ];
- if ( ! override ) {
- return notice;
- }
- if ( override.contentCheck && ! override.contentCheck( notice.content ) ) {
+ // A plain lookup would resolve ids like `constructor` or `toString` to
+ // an inherited `Object.prototype` member instead of `undefined`.
+ if ( ! Object.prototype.hasOwnProperty.call( overrides, notice.id ) ) {
return notice;
}
+ const override = overrides[ notice.id ];
+
+ const rewriteText =
+ ! override.labelKeys ||
+ override.labelKeys.some(
+ ( key ) => labels?.[ key ] && labels[ key ] === notice.content
+ );
+
+ const content =
+ notice.id === 'editor-save' && isTemplatePostType( postType )
+ ? EMAIL_DESIGN_UPDATED_MESSAGE
+ : override.content;
+
return {
...notice,
- content: override.content,
- spokenMessage: override.content,
+ ...( rewriteText ? { content, spokenMessage: content } : {} ),
actions: override.removeActions ? [] : notice.actions,
};
}
-function applyOverridesToNotices( notices: Notice[] ): Notice[] {
- return notices.map( ( notice ) => transformNotice( notice ) );
+function applyOverridesToNotices(
+ notices: Notice[],
+ labels: PostTypeLabels,
+ postType: string | undefined
+): Notice[] {
+ return notices.map( ( notice ) =>
+ transformNotice( notice, labels, postType )
+ );
}
function getStoreName( namespace: string | { name: string } ): string {
@@ -74,8 +139,16 @@ function getStoreName( namespace: string | { name: string } ): string {
}
const getNoticesWithOverrides = createSelector(
- ( notices: Notice[] ) => applyOverridesToNotices( notices ),
- ( notices: Notice[] ) => [ notices ]
+ (
+ notices: Notice[],
+ labels: PostTypeLabels,
+ postType: string | undefined
+ ) => applyOverridesToNotices( notices, labels, postType ),
+ (
+ notices: Notice[],
+ labels: PostTypeLabels,
+ postType: string | undefined
+ ) => [ notices, labels, postType ]
);
/**
@@ -107,10 +180,50 @@ export function useNoticeOverrides(): void {
return {
...selectors,
- getNotices: ( context?: string ) =>
- getNoticesWithOverrides(
- originalGetNotices( context )
- ),
+ getNotices: ( context?: string ) => {
+ const notices = originalGetNotices( context );
+ const overrides = getNoticeOverrides();
+ const hasOverridableNotice = notices.some(
+ ( notice ) =>
+ Object.prototype.hasOwnProperty.call(
+ overrides,
+ notice.id
+ )
+ );
+
+ if ( ! hasOverridableNotice ) {
+ return getNoticesWithOverrides(
+ notices,
+ undefined,
+ undefined
+ );
+ }
+
+ const postType = (
+ originalSelect( storeName ) as
+ | { getEmailPostType?: () => string }
+ | undefined
+ )?.getEmailPostType?.();
+ const labels = postType
+ ? (
+ originalSelect( coreStore ) as
+ | {
+ getPostType: (
+ postType: string
+ ) => {
+ labels?: PostTypeLabels;
+ };
+ }
+ | undefined
+ )?.getPostType( postType )?.labels
+ : undefined;
+
+ return getNoticesWithOverrides(
+ notices,
+ labels,
+ postType
+ );
+ },
};
},
};