diff --git a/packages/js/src/bulk-editor/components/bulk-editor-content.js b/packages/js/src/bulk-editor/components/bulk-editor-content.js
index df276b6db24..1d34c1dae99 100644
--- a/packages/js/src/bulk-editor/components/bulk-editor-content.js
+++ b/packages/js/src/bulk-editor/components/bulk-editor-content.js
@@ -1,7 +1,8 @@
+import { Slot } from "@wordpress/components";
import { useDispatch, useSelect } from "@wordpress/data";
import { useCallback, useEffect, useMemo, useState } from "@wordpress/element";
import { __ } from "@wordpress/i18n";
-import { STORE_NAME } from "../constants";
+import { PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../constants";
import { getFieldSets } from "../field-sets";
import { useInlineEdit } from "../hooks/use-inline-edit";
import { usePosts } from "../services/use-posts";
@@ -53,12 +54,14 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
() => Object.values( fieldSets ).map( ( { id, label } ) => ( { id, label } ) ),
[ fieldSets ]
);
- const { activeFieldSet, selectedIds, isPremium } = useSelect( ( select ) => {
+ const { activeFieldSet, selectedIds, isPremium, hasExternalPendingChanges } = useSelect( ( select ) => {
const store = select( STORE_NAME );
return {
activeFieldSet: store.selectActiveFieldSet(),
selectedIds: store.selectSelectedIds(),
isPremium: store.selectPreference( "isPremium", false ),
+ // An external plugin (e.g. Premium's AI suggestions) reports pending changes so the switch can be guarded.
+ hasExternalPendingChanges: store.selectHasExternalPendingChanges(),
};
}, [] );
const { setActiveFieldSet, toggleRow, selectAll, deselectAll } = useDispatch( STORE_NAME );
@@ -66,7 +69,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
const { data: items = [], total = 0, totalPages = 0, isPending, updateItem } = usePosts( { dataProvider, remoteDataProvider, contentType } );
const { editing, stopEditing } = useInlineEdit( { dataProvider, remoteDataProvider, fieldSets, activeFieldSet, items, updateItem } );
- // The tab the user wants to switch to while rows still have unsaved edits; drives the confirmation modal.
+ // The tab the user wants to switch to while a switch is guarded (unsaved manual edits, or an external plugin
+ // reporting pending changes); drives the confirmation modal.
const [ pendingTab, setPendingTab ] = useState( null );
const hasUnsavedEdits = Object.keys( editing.editingRows ).length > 0;
@@ -75,13 +79,14 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
if ( id === activeFieldSet ) {
return;
}
- // Guard the switch when edits are in progress; otherwise switch straight away.
- if ( Object.keys( editing.editingRows ).length > 0 ) {
+ // Guard the switch when manual edits are in progress or an external plugin (Premium AI) reports pending
+ // changes; otherwise switch straight away. The guarded tab is held in pendingTab until the user decides.
+ if ( hasUnsavedEdits || hasExternalPendingChanges ) {
setPendingTab( id );
return;
}
setActiveFieldSet( id );
- }, [ activeFieldSet, editing.editingRows, setActiveFieldSet ] );
+ }, [ activeFieldSet, hasUnsavedEdits, hasExternalPendingChanges, setActiveFieldSet ] );
const onSaveAndSwitch = useCallback( () => {
// Fire the save for every open field; each reads its draft synchronously, so clearing the edit state
@@ -102,6 +107,22 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
const onCancelSwitch = useCallback( () => setPendingTab( null ), [] );
+ // Commits the deferred switch for an external guard (Premium fills the slot below and calls this once it has
+ // handled its own pending changes). Free's own manual edits use onSaveAndSwitch/onDiscardAndSwitch instead.
+ const onCommitSwitch = useCallback( () => {
+ setActiveFieldSet( pendingTab );
+ setPendingTab( null );
+ }, [ pendingTab, setActiveFieldSet ] );
+
+ // Self-heal a stranded switch: if a deferral is outstanding but nothing guards it any more (manual edits saved
+ // and the external plugin cleared its pending changes), complete the switch so the user can never get stuck on a
+ // tab with no modal to resolve.
+ useEffect( () => {
+ if ( pendingTab !== null && ! hasUnsavedEdits && ! hasExternalPendingChanges ) {
+ onCommitSwitch();
+ }
+ }, [ pendingTab, hasUnsavedEdits, hasExternalPendingChanges, onCommitSwitch ] );
+
useEffect( () => {
deselectAll();
}, [ contentType, deselectAll ] );
@@ -173,6 +194,15 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
onDiscard={ onDiscardAndSwitch }
onClose={ onCancelSwitch }
/>
+
+
);
};
diff --git a/packages/js/src/bulk-editor/constants.js b/packages/js/src/bulk-editor/constants.js
index b21325da12d..33f403866e2 100644
--- a/packages/js/src/bulk-editor/constants.js
+++ b/packages/js/src/bulk-editor/constants.js
@@ -32,6 +32,10 @@ export const SELECT_MENU_ITEMS_FILTER = "yoast.bulkEditor.selectMenuItems";
// fillProps: { field, item, value, isSaving, onSaveField, onDiscardField }.
export const TABLE_CELL_FIELD_SLOT = "yoast.bulkEditor.TableCellWithField";
+// The slot Premium fills with its own pending-changes confirmation modal (e.g. unapplied AI suggestions), shown when a
+// tab switch is deferred because an external plugin reports pending changes. fillProps: { isOpen, onCommit, onCancel }.
+export const PENDING_CHANGES_MODAL_SLOT = "yoast.bulkEditor.pendingChangesModal";
+
// The WooCommerce product post type.
export const PRODUCT_CONTENT_TYPE = "product";
diff --git a/packages/js/src/bulk-editor/store/external-pending-changes.js b/packages/js/src/bulk-editor/store/external-pending-changes.js
new file mode 100644
index 00000000000..e0f198357dc
--- /dev/null
+++ b/packages/js/src/bulk-editor/store/external-pending-changes.js
@@ -0,0 +1,25 @@
+import { createSlice } from "@reduxjs/toolkit";
+import { get } from "lodash";
+
+/**
+ * @returns {boolean} The initial external-pending-changes state.
+ */
+export const createInitialExternalPendingChangesState = () => false;
+
+const slice = createSlice( {
+ name: "externalPendingChanges",
+ initialState: createInitialExternalPendingChangesState(),
+ reducers: {
+ // An external plugin (e.g. Premium's AI suggestions) reports whether it has pending changes, so the tab-change
+ // guard can defer the switch and let that plugin confirm via its own modal.
+ setHasExternalPendingChanges: ( state, { payload } ) => Boolean( payload ),
+ },
+} );
+
+export const externalPendingChangesSelectors = {
+ selectHasExternalPendingChanges: ( state ) => get( state, "externalPendingChanges", false ),
+};
+
+export const externalPendingChangesActions = slice.actions;
+
+export default slice.reducer;
diff --git a/packages/js/src/bulk-editor/store/index.js b/packages/js/src/bulk-editor/store/index.js
index a40f62ac5ba..cb5afac3673 100644
--- a/packages/js/src/bulk-editor/store/index.js
+++ b/packages/js/src/bulk-editor/store/index.js
@@ -5,6 +5,11 @@ import { STORE_NAME } from "../constants";
import activeContentType, { activeContentTypeActions, activeContentTypeSelectors, createInitialActiveContentTypeState } from "./active-content-type";
import activeFieldSet, { activeFieldSetActions, activeFieldSetSelectors, createInitialActiveFieldSetState } from "./active-field-set";
import edits, { createInitialEditsState, editsActions, editsSelectors } from "./edits";
+import externalPendingChanges, {
+ createInitialExternalPendingChangesState,
+ externalPendingChangesActions,
+ externalPendingChangesSelectors,
+} from "./external-pending-changes";
import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences";
import query, { createInitialQueryState, queryActions, querySelectors } from "./query";
import selection, { createInitialSelectionState, selectionActions, selectionSelectors } from "./selection";
@@ -25,6 +30,7 @@ const createStore = ( { initialState } ) => {
...queryActions,
...selectionActions,
...editsActions,
+ ...externalPendingChangesActions,
},
selectors: {
...linkParamsSelectors,
@@ -34,6 +40,7 @@ const createStore = ( { initialState } ) => {
...querySelectors,
...selectionSelectors,
...editsSelectors,
+ ...externalPendingChangesSelectors,
},
initialState: merge(
{},
@@ -45,6 +52,7 @@ const createStore = ( { initialState } ) => {
query: createInitialQueryState(),
selection: createInitialSelectionState(),
edits: createInitialEditsState(),
+ externalPendingChanges: createInitialExternalPendingChangesState(),
},
initialState
),
@@ -56,6 +64,7 @@ const createStore = ( { initialState } ) => {
query,
selection,
edits,
+ externalPendingChanges,
} ),
} );
};
diff --git a/packages/js/tests/bulk-editor/store/external-pending-changes.test.js b/packages/js/tests/bulk-editor/store/external-pending-changes.test.js
new file mode 100644
index 00000000000..0a99ac0b66f
--- /dev/null
+++ b/packages/js/tests/bulk-editor/store/external-pending-changes.test.js
@@ -0,0 +1,27 @@
+import reducer, {
+ createInitialExternalPendingChangesState,
+ externalPendingChangesActions,
+ externalPendingChangesSelectors,
+} from "../../../src/bulk-editor/store/external-pending-changes";
+
+describe( "external-pending-changes slice", () => {
+ it( "defaults to no pending changes", () => {
+ expect( createInitialExternalPendingChangesState() ).toBe( false );
+ } );
+
+ it( "updates the state via setHasExternalPendingChanges", () => {
+ expect( reducer( false, externalPendingChangesActions.setHasExternalPendingChanges( true ) ) ).toBe( true );
+ } );
+
+ it( "coerces the payload to a boolean", () => {
+ expect( reducer( true, externalPendingChangesActions.setHasExternalPendingChanges( 0 ) ) ).toBe( false );
+ } );
+
+ it( "selects the pending state from the store state", () => {
+ expect( externalPendingChangesSelectors.selectHasExternalPendingChanges( { externalPendingChanges: true } ) ).toBe( true );
+ } );
+
+ it( "falls back to false when the state is missing", () => {
+ expect( externalPendingChangesSelectors.selectHasExternalPendingChanges( {} ) ).toBe( false );
+ } );
+} );