Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions packages/js/src/bulk-editor/components/bulk-editor-content.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -53,20 +54,23 @@ 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 );

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;

Expand All @@ -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
Expand All @@ -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 ] );
Expand Down Expand Up @@ -173,6 +194,15 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
onDiscard={ onDiscardAndSwitch }
onClose={ onCancelSwitch }
/>
<BulkEditorFooter total={ total } totalPages={ totalPages } isPending={ isPending } />
<Slot
name={ PENDING_CHANGES_MODAL_SLOT }
fillProps={ {
isOpen: pendingTab !== null && ! hasUnsavedEdits,
onCommit: onCommitSwitch,
onCancel: onCancelSwitch,
} }
/>
</div>
);
};
4 changes: 4 additions & 0 deletions packages/js/src/bulk-editor/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
25 changes: 25 additions & 0 deletions packages/js/src/bulk-editor/store/external-pending-changes.js
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 9 additions & 0 deletions packages/js/src/bulk-editor/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,6 +30,7 @@ const createStore = ( { initialState } ) => {
...queryActions,
...selectionActions,
...editsActions,
...externalPendingChangesActions,
},
selectors: {
...linkParamsSelectors,
Expand All @@ -34,6 +40,7 @@ const createStore = ( { initialState } ) => {
...querySelectors,
...selectionSelectors,
...editsSelectors,
...externalPendingChangesSelectors,
},
initialState: merge(
{},
Expand All @@ -45,6 +52,7 @@ const createStore = ( { initialState } ) => {
query: createInitialQueryState(),
selection: createInitialSelectionState(),
edits: createInitialEditsState(),
externalPendingChanges: createInitialExternalPendingChangesState(),
},
initialState
),
Expand All @@ -56,6 +64,7 @@ const createStore = ( { initialState } ) => {
query,
selection,
edits,
externalPendingChanges,
} ),
} );
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
Loading