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 ); + } ); +} );