diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css
index 6e0b92e72ef..cbb90ec9847 100644
--- a/css/src/bulk-editor-page.css
+++ b/css/src/bulk-editor-page.css
@@ -102,6 +102,12 @@
@apply yst-m-0;
}
+/* The "Overview selection" filter group. It is separated from the status filters below it by a divider,
+ * mirroring the one above the "needs improvement" group. */
+.yst-root .yst-bulk-editor-overview-selection {
+ @apply yst-border-b yst-border-slate-200 yst-mb-0.5 yst-pb-0.5;
+}
+
/* The "needs improvement" filter group. It is separated from the status filters by a divider; each option is
* prefixed with a red score dot; and the group's legend is hidden visually but kept for assistive tech, so the
* dot's "needs improvement" meaning is never conveyed by colour alone. */
diff --git a/packages/js/src/bulk-editor/components/bulk-action-bar.js b/packages/js/src/bulk-editor/components/bulk-action-bar.js
index ae5a4f82f55..345ed34b6a6 100644
--- a/packages/js/src/bulk-editor/components/bulk-action-bar.js
+++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js
@@ -1,12 +1,14 @@
import CheckIcon from "@heroicons/react/outline/CheckIcon";
import XIcon from "@heroicons/react/outline/XIcon";
-import SolidXIcon from "@heroicons/react/solid/XIcon";
import { Slot } from "@wordpress/components";
import { useEffect, useId, useRef } from "@wordpress/element";
import { __, _n, sprintf } from "@wordpress/i18n";
-import { Alert, Button, Checkbox, useSvgAria, useToggleState } from "@yoast/ui-library";
+import { Button, Checkbox, useSvgAria, useToggleState } from "@yoast/ui-library";
import { BULK_ACTIONS_SLOT, BULK_NOTICES_SLOT } from "../constants";
import { useAiUpsell } from "../hooks/use-ai-upsell";
+import { DismissibleAlert } from "./dismissible-alert";
+import { OverviewExclusionNotice } from "./overview-exclusion-notice";
+import { OverviewSelectionNotice } from "./overview-selection-notice";
import { UpsellModal } from "./upsell-modal";
import { SelectMenu } from "./select-menu";
@@ -142,29 +144,25 @@ export const ManualReviewActions = ( { editCount, onApplyAll, onDiscardAll, isAp
*
* @returns {JSX.Element} The save-error notice.
*/
-export const ManualSaveErrorNotice = ( { onDismiss } ) => {
- const svgAriaProps = useSvgAria();
- return
+export const ManualSaveErrorNotice = ( { onDismiss } ) => (
+
{ __( "Couldn't save your edits.", "wordpress-seo" ) }{ __( "Something went wrong. Please try again.", "wordpress-seo" ) }
-
- ;
-};
+
+);
/**
- * The Free save-error notice, and the alerts slot Premium fills (e.g. its AI alerts).
- * Only rendered on the active tab, so each tab has a single slot to target.
+ * The overview-selection truncation and exclusion notices, the Free save-error notice, and the alerts slot
+ * Premium fills (e.g. its AI alerts). Only rendered on the active tab, so each tab has a single slot to target.
+ * The truncation and exclusion notices are independent and can show at the same time.
*
* @param {Object} props The props.
+ * @param {number} [props.preselectedTotal] How many items were selected on the WP admin overview; shows the truncation notice.
+ * @param {Function} [props.onDismissPreselection] Dismisses the truncation notice.
+ * @param {boolean} [props.hasExcludedPreselected] Whether carried-over items were dropped; shows the exclusion notice.
+ * @param {Function} [props.onDismissExclusion] Dismisses the exclusion notice.
* @param {boolean} [props.hasSaveError] Whether the last apply-all failed; shows the save-error notice.
* @param {Function} [props.onDismissSaveError] Dismisses the save-error notice.
* @param {number[]} props.selectedIds The ids of the selected rows, passed to the notices fill.
@@ -176,9 +174,12 @@ export const ManualSaveErrorNotice = ( { onDismiss } ) => {
* @returns {JSX.Element} The notices region.
*/
const BulkActionsNotices = ( {
- hasSaveError, onDismissSaveError, selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel,
+ preselectedTotal = 0, onDismissPreselection, hasExcludedPreselected = false, onDismissExclusion, hasSaveError, onDismissSaveError,
+ selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel,
} ) => (
<>
+
+
{ hasSaveError && }
(
{ isActive && (
+ ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges || hasOverviewNotice;
+
+/**
+ * Decides whether an overview-selection notice (truncation or exclusion) must show.
+ *
+ * @param {Object} view The view state.
+ * @param {number} view.preselectedTotal How many items were selected on the WP admin overview.
+ * @param {boolean} view.hasExcludedPreselected Whether pruning dropped carried-over ids.
+ *
+ * @returns {boolean} Whether an overview-selection notice must show.
+ */
+export const getHasOverviewNotice = ( { preselectedTotal, hasExcludedPreselected } ) =>
+ preselectedTotal > BULK_UPDATE_BATCH_SIZE || hasExcludedPreselected;
+
/**
* The bulk editor content.
*
@@ -39,6 +74,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
const {
activeFieldSet,
selectedIds,
+ preselectedTotal,
+ hasExcludedPreselected,
isPremium,
isAiEnabled,
hasExternalPendingChanges,
@@ -49,6 +86,10 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
return {
activeFieldSet: store.selectActiveFieldSet(),
selectedIds: store.selectSelectedIds(),
+ // The size of a selection carried over from the WP admin overview; drives the truncation notice.
+ preselectedTotal: store.selectPreselectedTotal(),
+ // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice.
+ hasExcludedPreselected: store.selectHasExcludedPreselected(),
isPremium: store.selectPreference( "isPremium", false ),
isAiEnabled: store.selectPreference( "isAiEnabled", false ),
// An external plugin (e.g. Premium's AI suggestions) reports pending changes so the switch can be guarded.
@@ -58,7 +99,9 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
pendingSwitch: store.selectPendingSwitch(),
};
}, [] );
- const { requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll } = useDispatch( STORE_NAME );
+ const {
+ requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll, dismissPreselectionNotice, dismissExclusionNotice,
+ } = 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 } );
@@ -105,6 +148,10 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
}, [ pendingSwitch, hasUnsavedEdits, hasExternalPendingChanges, onCommitSwitch ] );
const { isAllSelected, isIndeterminate, selectedCount, totalCount, hasSelection } = getSelectionView( isPending, selectedIds, items, total );
+ // The truncation and exclusion notices for a selection carried over from the WP admin overview, shown in the
+ // band's notices region; either one keeps the band expanded.
+ const hasOverviewNotice = getHasOverviewNotice( { preselectedTotal, hasExcludedPreselected } );
+ const showBulkActions = shouldShowBulkActions( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } );
const onSelectAll = useCallback( () => {
if ( ! isPending ) {
// Only posts the user can edit are selectable for bulk editing.
@@ -174,14 +221,13 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
isApplyingAll={ editing.isApplyingAll }
hasSaveError={ editing.hasSaveError }
onDismissSaveError={ editing.dismissSaveError }
+ preselectedTotal={ preselectedTotal }
+ onDismissPreselection={ dismissPreselectionNotice }
+ hasExcludedPreselected={ hasExcludedPreselected }
+ onDismissExclusion={ dismissExclusionNotice }
/>
}
- // A selection only warrants the band while AI is enabled (the AI affordances are its only
- // selection-driven occupant); with AI off the band collapses. Unsaved manual edits are a
- // separate, non-AI occupant, so they keep it open regardless of the AI toggle. External
- // pending changes (Premium's AI suggestions) also keep it open: a filter, search, or page
- // change clears the selection but must leave the pending suggestions actionable.
- showBulkActions={ ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges }
+ showBulkActions={ showBulkActions }
filters={ }
isLoading={ isPending }
hasExternalPendingChanges={ hasExternalPendingChanges }
diff --git a/packages/js/src/bulk-editor/components/bulk-editor-filters.js b/packages/js/src/bulk-editor/components/bulk-editor-filters.js
index 3317cc1c5d1..1d029a474f0 100644
--- a/packages/js/src/bulk-editor/components/bulk-editor-filters.js
+++ b/packages/js/src/bulk-editor/components/bulk-editor-filters.js
@@ -6,8 +6,9 @@ import { Badge, Button, CheckboxGroup, Popover, useSvgAria } from "@yoast/ui-lib
import { FIELD_SET_SOCIAL, NEEDS_IMPROVEMENT_DESCRIPTION, NEEDS_IMPROVEMENT_TITLE, STORE_NAME } from "../constants";
/**
- * The Filters button and popover: narrows the table by post status and by which fields need improvement.
- * A badge shows how many filters are applied.
+ * The Filters button and popover: narrows the table by post status, by which fields need improvement
+ * and, when a selection was carried over from the WP admin overview, by that selection. A badge shows
+ * how many filters are applied.
*
* The "needs improvement" options are tab-agnostic (values {@link NEEDS_IMPROVEMENT_TITLE} /
* {@link NEEDS_IMPROVEMENT_DESCRIPTION}); only their labels change with the active tab, so a checked box
@@ -19,7 +20,9 @@ export const BulkEditorFilters = () => {
const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] );
const needsImprovement = useSelect( ( select ) => select( STORE_NAME ).selectNeedsImprovement(), [] );
const activeFieldSet = useSelect( ( select ) => select( STORE_NAME ).selectActiveFieldSet(), [] );
- const { setStatuses, setNeedsImprovement } = useDispatch( STORE_NAME );
+ const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] );
+ const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] );
+ const { setStatuses, setNeedsImprovement, setOverviewFilterActive } = useDispatch( STORE_NAME );
const [ isOpen, setIsOpen ] = useState( false );
const containerRef = useRef( null );
const triggerRef = useRef( null );
@@ -32,6 +35,15 @@ export const BulkEditorFilters = () => {
{ value: "draft", label: __( "Draft", "wordpress-seo" ) },
], [] );
+ const overviewOptions = useMemo( () => [
+ { value: "overview", label: __( "Overview selection", "wordpress-seo" ) },
+ ], [] );
+
+ const onChangeOverviewFilter = useCallback(
+ ( values ) => setOverviewFilterActive( values.includes( "overview" ) ),
+ [ setOverviewFilterActive ]
+ );
+
const isSocial = activeFieldSet === FIELD_SET_SOCIAL;
// The red dot in front of each option (and the group's "needs improvement" legend) carries the
// "needs improvement" meaning, so the visible labels are the plain field names.
@@ -79,7 +91,7 @@ export const BulkEditorFilters = () => {
};
}, [ isOpen ] );
- const appliedCount = statuses.length + needsImprovement.length;
+ const appliedCount = statuses.length + needsImprovement.length + ( isOverviewFilterActive ? 1 : 0 );
return (
@@ -95,6 +107,15 @@ export const BulkEditorFilters = () => {
className="before:yst-hidden !yst-top-full yst-mt-1 yst-py-0.5 yst-px-0 yst-shadow-lg"
aria-label={ __( "Filters", "wordpress-seo" ) }
>
+ { overviewIds.length > 0 && (
+
+ ) }
{
+ const svgAriaProps = useSvgAria();
+
+ return (
+
+ { children }
+
+
+ );
+};
diff --git a/packages/js/src/bulk-editor/components/overview-exclusion-notice.js b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js
new file mode 100644
index 00000000000..7f7e148a6d6
--- /dev/null
+++ b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js
@@ -0,0 +1,28 @@
+import { __ } from "@wordpress/i18n";
+import { DismissibleAlert } from "./dismissible-alert";
+
+/**
+ * The notice shown when a selection carried over from a WP admin overview contained items the bulk editor
+ * cannot show or edit: those were dropped from the selection. Renders nothing while nothing was dropped.
+ *
+ * @param {Object} props The props.
+ * @param {boolean} props.hasExclusions Whether carried-over items were dropped from the selection.
+ * @param {Function} props.onDismiss Dismisses the notice.
+ *
+ * @returns {?JSX.Element} The notice, or null while nothing was dropped.
+ */
+export const OverviewExclusionNotice = ( { hasExclusions, onDismiss } ) => {
+ if ( ! hasExclusions ) {
+ return null;
+ }
+
+ return (
+ // The top margin separates this notice from the truncation notice above it; it cancels out when
+ // nothing precedes it in the notices region (the truncation notice renders null when it does not apply).
+
+
+ { __( "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded.", "wordpress-seo" ) }
+
+
+ );
+};
diff --git a/packages/js/src/bulk-editor/components/overview-selection-notice.js b/packages/js/src/bulk-editor/components/overview-selection-notice.js
new file mode 100644
index 00000000000..847c77faa4d
--- /dev/null
+++ b/packages/js/src/bulk-editor/components/overview-selection-notice.js
@@ -0,0 +1,32 @@
+import { __, sprintf } from "@wordpress/i18n";
+import { BULK_UPDATE_BATCH_SIZE } from "../constants";
+import { DismissibleAlert } from "./dismissible-alert";
+
+/**
+ * The notice shown when the user arrived from a WP admin overview with more items selected than the
+ * bulk editor can handle in one batch: only the first batch stays selected. Renders nothing while the
+ * whole selection fits the batch.
+ *
+ * @param {Object} props The props.
+ * @param {number} props.total The number of items that were selected on the overview.
+ * @param {Function} props.onDismiss Dismisses the notice.
+ *
+ * @returns {?JSX.Element} The notice, or null when the whole selection fits the batch.
+ */
+export const OverviewSelectionNotice = ( { total, onDismiss } ) => {
+ if ( total <= BULK_UPDATE_BATCH_SIZE ) {
+ return null;
+ }
+
+ const message = sprintf(
+ /* translators: %1$d expands to the maximum number of items at a time. */
+ __( "Only the first %1$d items from your selection were carried over. The bulk editor supports up to %1$d items at a time.", "wordpress-seo" ),
+ BULK_UPDATE_BATCH_SIZE
+ );
+
+ return (
+
+ { message }
+
+ );
+};
diff --git a/packages/js/src/bulk-editor/helpers/get-selection-view.js b/packages/js/src/bulk-editor/helpers/get-selection-view.js
index edfa3d1ccdf..f0774ab2be5 100644
--- a/packages/js/src/bulk-editor/helpers/get-selection-view.js
+++ b/packages/js/src/bulk-editor/helpers/get-selection-view.js
@@ -14,9 +14,11 @@ export const getSelectionView = ( isLoading, selectedIds, items, total ) => {
return { isAllSelected: false, isIndeterminate: false, selectedCount: 0, totalCount: 0, hasSelection: false };
}
// Only posts the user can edit are selectable, so "all selected" is measured against the editable rows.
- const selectableCount = items.filter( ( item ) => item.editable ).length;
+ // Measured by membership, not count: a selection carried over from the WP admin overview can contain
+ // rows that are not on the current page.
+ const selectableIds = items.filter( ( item ) => item.editable ).map( ( item ) => item.id );
const selectedCount = selectedIds.length;
- const isAllSelected = selectableCount > 0 && selectedCount === selectableCount;
+ const isAllSelected = selectableIds.length > 0 && selectableIds.every( ( id ) => selectedIds.includes( id ) );
return {
isAllSelected,
isIndeterminate: selectedCount > 0 && ! isAllSelected,
diff --git a/packages/js/src/bulk-editor/hooks/use-posts.js b/packages/js/src/bulk-editor/hooks/use-posts.js
index cdd5b0684bc..803f3616623 100644
--- a/packages/js/src/bulk-editor/hooks/use-posts.js
+++ b/packages/js/src/bulk-editor/hooks/use-posts.js
@@ -1,4 +1,4 @@
-import { useSelect } from "@wordpress/data";
+import { useDispatch, useSelect } from "@wordpress/data";
import { useCallback, useEffect, useMemo, useRef, useState } from "@wordpress/element";
import { NEEDS_IMPROVEMENT_FIELD_PARAMS, PAGE_SIZE, STORE_NAME } from "../constants";
@@ -66,6 +66,9 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) =>
const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] );
const needsImprovement = useSelect( ( select ) => select( STORE_NAME ).selectNeedsImprovement(), [] );
const activeFieldSet = useSelect( ( select ) => select( STORE_NAME ).selectActiveFieldSet(), [] );
+ const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] );
+ const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] );
+ const { pruneSelection } = useDispatch( STORE_NAME );
// Resolve the tab-agnostic "needs improvement" concepts to the active tab's concrete field params.
const needsImprovementFields = useMemo( () => {
@@ -91,24 +94,36 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) =>
setState( ( previous ) => ( { ...previous, isPending: true } ) );
+ const params = {
+ /* eslint-disable camelcase -- The REST endpoint expects snake_case query parameters. */
+ content_type: contentType,
+ per_page: String( PAGE_SIZE ),
+ page: String( page ),
+ search,
+ status: statuses,
+ needs_improvement: needsImprovementFields,
+ /* eslint-enable camelcase */
+ };
+ // The "Overview selection" filter narrows the list to the posts carried over from the WP admin overview.
+ if ( isOverviewFilterActive && overviewIds.length > 0 ) {
+ params.include = overviewIds.map( String );
+ }
+
remoteDataProvider
.fetchJson(
endpoint,
- {
- /* eslint-disable camelcase -- The REST endpoint expects snake_case query parameters. */
- content_type: contentType,
- per_page: String( PAGE_SIZE ),
- page: String( page ),
- search,
- status: statuses,
- needs_improvement: needsImprovementFields,
- /* eslint-enable camelcase */
- },
+ params,
{ signal: current.signal }
)
.then( ( response ) => {
if ( controller.current === current ) {
- setState( formatResponse( response ) );
+ const settled = formatResponse( response );
+ setState( settled );
+ // While the overview filter is active, the response is the authoritative subset of the
+ // carried-over selection (at most one page): prune ids it cannot offer for selection.
+ if ( isOverviewFilterActive && overviewIds.length > 0 ) {
+ pruneSelection( settled.data.filter( ( item ) => item.editable ).map( ( item ) => item.id ) );
+ }
}
} )
.catch( ( error ) => {
@@ -119,7 +134,10 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) =>
} );
return () => current.abort();
- }, [ endpoint, contentType, remoteDataProvider, search, page, statuses, needsImprovementFields ] );
+ }, [
+ endpoint, contentType, remoteDataProvider, search, page, statuses,
+ needsImprovementFields, overviewIds, isOverviewFilterActive, pruneSelection,
+ ] );
return { ...state, updateItem };
};
diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js
index 4fc69adb870..198cb6f741c 100644
--- a/packages/js/src/bulk-editor/initialize.js
+++ b/packages/js/src/bulk-editor/initialize.js
@@ -11,7 +11,7 @@ import { fixWordPressMenuScrolling } from "../shared-admin/helpers";
import { getMyyoastConnectionState, LINK_PARAMS_NAME, MYYOAST_CONNECTION_NAME } from "../shared-admin/store";
import App from "./app";
import { UpsellModal } from "./components/upsell-modal";
-import { PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants";
+import { BULK_UPDATE_BATCH_SIZE, PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants";
import { useAiUpsell } from "./hooks/use-ai-upsell";
import { DataProvider } from "./services";
import registerStore from "./store";
@@ -24,6 +24,33 @@ window.yoast.bulkEditor = window.yoast.bulkEditor || {};
window.yoast.bulkEditor.components = { ...window.yoast.bulkEditor.components, UpsellModal, GenericAlert };
window.yoast.bulkEditor.hooks = { ...window.yoast.bulkEditor.hooks, useAiUpsell };
+/**
+ * Builds the store state for a selection carried over from a WP admin overview bulk action.
+ *
+ * @param {Object} [initialSelection] The carried-over selection ({ contentType, postIds, selectedCount }), if any.
+ *
+ * @returns {Object} The seeded state: the active content type, the selection and the overview filter.
+ */
+export const getPreselectionState = ( initialSelection = {} ) => {
+ const selectedIds = ( Array.isArray( initialSelection.postIds ) ? initialSelection.postIds : [] )
+ .map( Number )
+ .filter( ( id ) => Number.isInteger( id ) && id > 0 )
+ .slice( 0, BULK_UPDATE_BATCH_SIZE );
+
+ return {
+ // An empty or unknown name resolves to the first available content type in the app.
+ activeContentType: typeof initialSelection.contentType === "string" ? initialSelection.contentType : "",
+ selection: {
+ selectedIds,
+ preselectedTotal: selectedIds.length > 0 ? Math.max( Number( initialSelection.selectedCount ) || 0, selectedIds.length ) : 0,
+ },
+ query: {
+ overviewIds: selectedIds,
+ isOverviewFilterActive: selectedIds.length > 0,
+ },
+ };
+};
+
domReady( () => {
const root = document.getElementById( ROOT_ID );
if ( ! root ) {
@@ -36,6 +63,7 @@ domReady( () => {
initialState: {
[ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ),
[ MYYOAST_CONNECTION_NAME ]: getMyyoastConnectionState( myyoastConnection ),
+ ...getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ),
},
} );
fixWordPressMenuScrolling();
diff --git a/packages/js/src/bulk-editor/store/query.js b/packages/js/src/bulk-editor/store/query.js
index ee130a7ec13..72f9dbb90d1 100644
--- a/packages/js/src/bulk-editor/store/query.js
+++ b/packages/js/src/bulk-editor/store/query.js
@@ -3,9 +3,19 @@ import { get } from "lodash";
import { activeContentTypeActions } from "./active-content-type";
/**
- * @returns {{search: string, page: number, statuses: string[], needsImprovement: string[]}} The initial table query state.
+ * @returns {{search: string, page: number, statuses: string[], needsImprovement: string[],
+ * overviewIds: number[], isOverviewFilterActive: boolean}} The initial table query state.
*/
-export const createInitialQueryState = () => ( { search: "", page: 1, statuses: [], needsImprovement: [] } );
+export const createInitialQueryState = () => ( {
+ search: "",
+ page: 1,
+ statuses: [],
+ needsImprovement: [],
+ // The post IDs carried over from the WP admin overview, backing the "Overview selection" filter;
+ // empty when the user did not come in via the overview's bulk action.
+ overviewIds: [],
+ isOverviewFilterActive: false,
+} );
const slice = createSlice( {
name: "query",
@@ -29,11 +39,19 @@ const slice = createSlice( {
state.needsImprovement = payload;
state.page = 1;
},
+ // Toggling the overview filter resets to the first page, like any other change to the result set.
+ setOverviewFilterActive: ( state, { payload } ) => {
+ state.isOverviewFilterActive = Boolean( payload );
+ state.page = 1;
+ },
},
extraReducers: ( builder ) => {
- // Switching content type resets to the first page: the current page may not exist in the new content type's results.
+ // Switching content type resets to the first page: the current page may not exist in the new content
+ // type's results. The overview selection belongs to the content type it was made for, so it goes too.
builder.addCase( activeContentTypeActions.setActiveContentType, ( state ) => {
state.page = 1;
+ state.overviewIds = [];
+ state.isOverviewFilterActive = false;
} );
},
} );
@@ -43,6 +61,8 @@ export const querySelectors = {
selectPage: ( state ) => get( state, "query.page", 1 ),
selectStatuses: ( state ) => get( state, "query.statuses", [] ),
selectNeedsImprovement: ( state ) => get( state, "query.needsImprovement", [] ),
+ selectOverviewIds: ( state ) => get( state, "query.overviewIds", [] ),
+ selectIsOverviewFilterActive: ( state ) => get( state, "query.isOverviewFilterActive", false ),
selectQuery: ( state ) => get( state, "query", createInitialQueryState() ),
};
diff --git a/packages/js/src/bulk-editor/store/selection.js b/packages/js/src/bulk-editor/store/selection.js
index feb058e80cd..9ae78530404 100644
--- a/packages/js/src/bulk-editor/store/selection.js
+++ b/packages/js/src/bulk-editor/store/selection.js
@@ -5,10 +5,16 @@ import { activeFieldSetActions } from "./active-field-set";
import { queryActions } from "./query";
/**
- * @returns {Object} The initial selection state: no rows selected.
+ * @returns {Object} The initial selection state: no rows selected, no selection carried over from the WP admin overview.
*/
export const createInitialSelectionState = () => ( {
selectedIds: [],
+ // How many items were selected on the WP admin overview when the user came in via its bulk action; drives the
+ // "only the first 20 are selected" notice. 0 when the user did not come in that way (or the notice was dismissed).
+ preselectedTotal: 0,
+ // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice.
+ // False when nothing was dropped (or the notice was dismissed).
+ hasExcludedPreselected: false,
} );
const slice = createSlice( {
@@ -24,15 +30,38 @@ const slice = createSlice( {
},
selectAll: ( state, { payload } ) => {
state.selectedIds = [ ...payload ];
+ // An explicit select-all replaces the carried-over selection, so its notices would be stale.
+ state.preselectedTotal = 0;
+ state.hasExcludedPreselected = false;
},
deselectAll: () => createInitialSelectionState(),
+ // A selection carried over from the WP admin overview can reference posts the bulk editor does not list
+ // (e.g. private posts) or lists with a disabled checkbox (non-editable rows). Once the shown result set is
+ // known, such ids must be dropped: they would stay selected without a way to deselect them, and leak into
+ // the bulk actions. Dropping any flags the exclusion notice, so the user learns why their selection shrank.
+ pruneSelection: ( state, { payload } ) => {
+ const pruned = state.selectedIds.filter( ( id ) => payload.includes( id ) );
+ if ( pruned.length < state.selectedIds.length ) {
+ state.hasExcludedPreselected = true;
+ }
+ state.selectedIds = pruned;
+ },
+ dismissPreselectionNotice: ( state ) => {
+ state.preselectedTotal = 0;
+ },
+ dismissExclusionNotice: ( state ) => {
+ state.hasExcludedPreselected = false;
+ },
},
extraReducers: ( builder ) => {
// Any change to the shown result set resets the selection: the new set may no longer contain the selected rows.
+ // The "Overview selection" filter (query slice) intentionally outlives these resets: it narrows the result set
+ // like any other filter, while the carried-over selection itself is one-shot and never restored.
builder.addCase( queryActions.setStatuses, () => createInitialSelectionState() );
builder.addCase( queryActions.setSearch, () => createInitialSelectionState() );
builder.addCase( queryActions.setNeedsImprovement, () => createInitialSelectionState() );
builder.addCase( queryActions.setPage, () => createInitialSelectionState() );
+ builder.addCase( queryActions.setOverviewFilterActive, () => createInitialSelectionState() );
builder.addCase( activeContentTypeActions.setActiveContentType, () => createInitialSelectionState() );
builder.addCase( activeFieldSetActions.setActiveFieldSet, () => createInitialSelectionState() );
},
@@ -40,6 +69,8 @@ const slice = createSlice( {
export const selectionSelectors = {
selectSelectedIds: ( state ) => get( state, "selection.selectedIds", [] ),
+ selectPreselectedTotal: ( state ) => get( state, "selection.preselectedTotal", 0 ),
+ selectHasExcludedPreselected: ( state ) => get( state, "selection.hasExcludedPreselected", false ),
};
export const selectionActions = slice.actions;
diff --git a/packages/js/tests/bulk-editor/bulk-editor-content.test.js b/packages/js/tests/bulk-editor/bulk-editor-content.test.js
index a3e79473aa8..553e4f0bb35 100644
--- a/packages/js/tests/bulk-editor/bulk-editor-content.test.js
+++ b/packages/js/tests/bulk-editor/bulk-editor-content.test.js
@@ -1,7 +1,7 @@
import { Fill, SlotFillProvider } from "@wordpress/components";
import { dispatch } from "@wordpress/data";
import { act, fireEvent, render, screen, waitFor } from "../test-utils";
-import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content";
+import { BulkEditorContent, getHasOverviewNotice, shouldShowBulkActions } from "../../src/bulk-editor/components/bulk-editor-content";
import { getSelectionView, getSmartSelectItems } from "../../src/bulk-editor/helpers";
import { FIELD_SET_SEARCH, FIELD_SET_SOCIAL, PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../../src/bulk-editor/constants";
import { DataProvider } from "../../src/bulk-editor/services";
@@ -121,6 +121,22 @@ describe( "getSelectionView", () => {
expect( view.totalCount ).toBe( 42 );
} );
+ it( "does not read a carried-over selection of other rows as all selected", () => {
+ // Two visible rows plus an id that is not on this page (carried over from the WP admin overview).
+ const view = getSelectionView( false, [ 1, 2, 99 ], items, 30 );
+
+ expect( view.isAllSelected ).toBe( false );
+ expect( view.isIndeterminate ).toBe( true );
+ expect( view.selectedCount ).toBe( 3 );
+ } );
+
+ it( "reads a selection covering every visible row as all selected, even with extra off-page rows", () => {
+ const view = getSelectionView( false, [ 1, 2, 3, 99 ], items, 30 );
+
+ expect( view.isAllSelected ).toBe( true );
+ expect( view.isIndeterminate ).toBe( false );
+ } );
+
it( "treats only editable rows as selectable", () => {
const mixed = [ { id: 1, editable: true }, { id: 2, editable: false }, { id: 3, editable: true } ];
@@ -133,6 +149,34 @@ describe( "getSelectionView", () => {
} );
} );
+describe( "shouldShowBulkActions", () => {
+ const closed = { hasSelection: false, isAiEnabled: false, hasUnsavedEdits: false, hasExternalPendingChanges: false, hasOverviewNotice: false };
+
+ it( "keeps the band closed when nothing occupies it", () => {
+ expect( shouldShowBulkActions( closed ) ).toBe( false );
+ } );
+
+ it( "opens the band for a selection only while AI is enabled", () => {
+ expect( shouldShowBulkActions( { ...closed, hasSelection: true } ) ).toBe( false );
+ expect( shouldShowBulkActions( { ...closed, hasSelection: true, isAiEnabled: true } ) ).toBe( true );
+ } );
+
+ it( "opens the band for unsaved edits, external pending changes and the overview notice", () => {
+ expect( shouldShowBulkActions( { ...closed, hasUnsavedEdits: true } ) ).toBe( true );
+ expect( shouldShowBulkActions( { ...closed, hasExternalPendingChanges: true } ) ).toBe( true );
+ expect( shouldShowBulkActions( { ...closed, hasOverviewNotice: true } ) ).toBe( true );
+ } );
+} );
+
+describe( "getHasOverviewNotice", () => {
+ it( "reports a notice for a truncated or a pruned carried-over selection, but not for a fitting one", () => {
+ expect( getHasOverviewNotice( { preselectedTotal: 0, hasExcludedPreselected: false } ) ).toBe( false );
+ expect( getHasOverviewNotice( { preselectedTotal: 20, hasExcludedPreselected: false } ) ).toBe( false );
+ expect( getHasOverviewNotice( { preselectedTotal: 25, hasExcludedPreselected: false } ) ).toBe( true );
+ expect( getHasOverviewNotice( { preselectedTotal: 3, hasExcludedPreselected: true } ) ).toBe( true );
+ } );
+} );
+
describe( "getSmartSelectItems", () => {
/* eslint-disable camelcase -- the needs-improvement map is keyed by backend field params. */
// id 3 is non-editable but needs improvement everywhere: it must never be selected.
diff --git a/packages/js/tests/bulk-editor/bulk-editor-filters.test.js b/packages/js/tests/bulk-editor/bulk-editor-filters.test.js
index b83f325a7b4..b0a59887f72 100644
--- a/packages/js/tests/bulk-editor/bulk-editor-filters.test.js
+++ b/packages/js/tests/bulk-editor/bulk-editor-filters.test.js
@@ -6,7 +6,9 @@ import registerStore from "../../src/bulk-editor/store";
describe( "BulkEditorFilters", () => {
beforeAll( () => {
- registerStore();
+ // Seeded with a carried-over overview selection, like initialize.js does, so the
+ // "Overview selection" filter is offered.
+ registerStore( { initialState: { query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true } } } );
} );
beforeEach( () => {
@@ -14,6 +16,7 @@ describe( "BulkEditorFilters", () => {
dispatch( STORE_NAME ).setStatuses( [] );
dispatch( STORE_NAME ).setNeedsImprovement( [] );
dispatch( STORE_NAME ).setActiveFieldSet( "search" );
+ dispatch( STORE_NAME ).setOverviewFilterActive( false );
} );
it( "renders the Filters button without a count badge when nothing is applied", () => {
@@ -81,6 +84,24 @@ describe( "BulkEditorFilters", () => {
expect( screen.getByText( "1" ) ).toBeInTheDocument();
} );
+ it( "offers the Overview selection filter while a carried-over selection exists", () => {
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: /Filters/ } ) );
+
+ expect( screen.getByRole( "checkbox", { name: "Overview selection" } ) ).toBeInTheDocument();
+ } );
+
+ it( "activates the overview filter, updates the store and counts it in the badge", () => {
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: /Filters/ } ) );
+ fireEvent.click( screen.getByRole( "checkbox", { name: "Overview selection" } ) );
+
+ expect( select( STORE_NAME ).selectIsOverviewFilterActive() ).toBe( true );
+ expect( screen.getByText( "1" ) ).toBeInTheDocument();
+ } );
+
it( "returns focus to the trigger when the dialog is closed with Escape", () => {
render( );
diff --git a/packages/js/tests/bulk-editor/hooks/use-posts.test.js b/packages/js/tests/bulk-editor/hooks/use-posts.test.js
index e01782e384c..8953eda2f14 100644
--- a/packages/js/tests/bulk-editor/hooks/use-posts.test.js
+++ b/packages/js/tests/bulk-editor/hooks/use-posts.test.js
@@ -1,25 +1,34 @@
import { renderHook, waitFor } from "@testing-library/react";
-import { useSelect } from "@wordpress/data";
+import { useDispatch, useSelect } from "@wordpress/data";
import { usePosts } from "../../../src/bulk-editor/hooks/use-posts";
import { PAGE_SIZE } from "../../../src/bulk-editor/constants";
-jest.mock( "@wordpress/data", () => ( { useSelect: jest.fn() } ) );
+jest.mock( "@wordpress/data", () => ( { useSelect: jest.fn(), useDispatch: jest.fn() } ) );
+
+// A stable fallback: a fresh [] per selector call would change identity on every render and
+// make the hook's effect refetch forever.
+const EMPTY = [];
describe( "usePosts", () => {
let dataProvider;
let storeState;
+ let pruneSelection;
beforeEach( () => {
dataProvider = { getEndpoint: jest.fn( () => "https://example.com/wp-json/yoast/v1/bulk_editor/posts" ) };
- storeState = { search: "", page: 1, statuses: [], needsImprovement: [], activeFieldSet: "search" };
+ storeState = { search: "", page: 1, statuses: [], needsImprovement: [], activeFieldSet: "search", overviewIds: [], isOverviewFilterActive: false };
// Resolve each useSelect call against our controllable store state.
useSelect.mockImplementation( ( mapSelect ) => mapSelect( () => ( {
selectSearch: () => storeState.search,
selectPage: () => storeState.page,
selectStatuses: () => storeState.statuses,
- selectNeedsImprovement: () => storeState.needsImprovement ?? [],
+ selectNeedsImprovement: () => storeState.needsImprovement ?? EMPTY,
selectActiveFieldSet: () => storeState.activeFieldSet ?? "search",
+ selectOverviewIds: () => storeState.overviewIds ?? EMPTY,
+ selectIsOverviewFilterActive: () => storeState.isOverviewFilterActive ?? false,
} ) ) );
+ pruneSelection = jest.fn();
+ useDispatch.mockReturnValue( { pruneSelection } );
} );
it( "requests the posts endpoint with the content type, page size, page, search, statuses and needs-improvement", async() => {
@@ -56,6 +65,75 @@ describe( "usePosts", () => {
);
} );
+ it( "requests only the carried-over posts while the overview filter is active", async() => {
+ storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: true };
+ const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [] } ) ) };
+
+ renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) );
+
+ await waitFor( () => expect( remoteDataProvider.fetchJson ).toHaveBeenCalled() );
+
+ expect( remoteDataProvider.fetchJson ).toHaveBeenCalledWith(
+ "https://example.com/wp-json/yoast/v1/bulk_editor/posts",
+ expect.objectContaining( { include: [ "5", "3" ] } ),
+ expect.objectContaining( { signal: expect.anything() } )
+ );
+ } );
+
+ it( "does not send the carried-over posts while the overview filter is inactive", async() => {
+ storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: false };
+ const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [] } ) ) };
+
+ renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) );
+
+ await waitFor( () => expect( remoteDataProvider.fetchJson ).toHaveBeenCalled() );
+
+ expect( remoteDataProvider.fetchJson ).toHaveBeenCalledWith(
+ "https://example.com/wp-json/yoast/v1/bulk_editor/posts",
+ expect.not.objectContaining( { include: expect.anything() } ),
+ expect.objectContaining( { signal: expect.anything() } )
+ );
+ } );
+
+ it( "prunes the selection to the editable listed rows while the overview filter is active", async() => {
+ storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3, 99 ], isOverviewFilterActive: true };
+ const remoteDataProvider = {
+ // Post 99 is not listed at all; post 3 is listed but not editable, so its checkbox is disabled.
+ fetchJson: jest.fn( () => Promise.resolve( { posts: [
+ { id: 5, title: "Listed", editable: true },
+ { id: 3, title: "Locked", editable: false },
+ ] } ) ),
+ };
+
+ const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) );
+
+ await waitFor( () => expect( result.current.isPending ).toBe( false ) );
+
+ expect( pruneSelection ).toHaveBeenCalledWith( [ 5 ] );
+ } );
+
+ it( "does not prune the selection while the overview filter is inactive", async() => {
+ storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: false };
+ const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [ { id: 5, title: "Listed", editable: true } ] } ) ) };
+
+ const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) );
+
+ await waitFor( () => expect( result.current.isPending ).toBe( false ) );
+
+ expect( pruneSelection ).not.toHaveBeenCalled();
+ } );
+
+ it( "does not prune the selection when the request fails", async() => {
+ storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: true };
+ const remoteDataProvider = { fetchJson: jest.fn( () => Promise.reject( new Error( "boom" ) ) ) };
+
+ const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) );
+
+ await waitFor( () => expect( result.current.isPending ).toBe( false ) );
+
+ expect( pruneSelection ).not.toHaveBeenCalled();
+ } );
+
it( "maps the snake_case API rows to camelCase bulk editor rows and exposes the totals", async() => {
const remoteDataProvider = {
/* eslint-disable camelcase -- The REST endpoint returns snake_case fields. */
diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js
index 17862417321..552baac94fa 100644
--- a/packages/js/tests/bulk-editor/initialize.test.js
+++ b/packages/js/tests/bulk-editor/initialize.test.js
@@ -90,6 +90,9 @@ describe( "bulk editor initialize", () => {
connectUrl: null,
learnMoreUrl: "",
},
+ activeContentType: "",
+ selection: { selectedIds: [], preselectedTotal: 0 },
+ query: { overviewIds: [], isOverviewFilterActive: false },
},
} );
expect( mockFixScrolling ).toHaveBeenCalledTimes( 1 );
@@ -97,6 +100,89 @@ describe( "bulk editor initialize", () => {
expect( mockRender ).toHaveBeenCalledTimes( 1 );
} );
+ test( "should seed the store with the selection carried over from the WP admin overview", () => {
+ const root = document.createElement( "div" );
+ root.id = ROOT_ID;
+ document.body.appendChild( root );
+ window.wpseoBulkEditorData.initialSelection = { contentType: "page", postIds: [ 5, 3 ], selectedCount: 25 };
+
+ jest.isolateModules( () => {
+ require( "../../src/bulk-editor/initialize" );
+ } );
+
+ expect( mockRegisterStore ).toHaveBeenCalledWith( {
+ initialState: {
+ linkParams: { foo: "bar" },
+ myyoastConnection: {
+ isAvailable: false,
+ canConnect: false,
+ connectUrl: null,
+ learnMoreUrl: "",
+ },
+ activeContentType: "page",
+ selection: { selectedIds: [ 5, 3 ], preselectedTotal: 25 },
+ query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true },
+ },
+ } );
+ } );
+
+ describe( "getPreselectionState", () => {
+ /**
+ * Requires the module in isolation and returns the getPreselectionState export.
+ *
+ * @returns {Function} The getPreselectionState function.
+ */
+ const requireGetPreselectionState = () => {
+ let getPreselectionState;
+ jest.isolateModules( () => {
+ ( { getPreselectionState } = require( "../../src/bulk-editor/initialize" ) );
+ } );
+ return getPreselectionState;
+ };
+
+ test( "should return an empty seed for a missing or malformed payload", () => {
+ const getPreselectionState = requireGetPreselectionState();
+
+ const emptySeed = {
+ activeContentType: "",
+ selection: { selectedIds: [], preselectedTotal: 0 },
+ query: { overviewIds: [], isOverviewFilterActive: false },
+ };
+ expect( getPreselectionState() ).toEqual( emptySeed );
+ expect( getPreselectionState( { contentType: 7, postIds: "5,3", selectedCount: 25 } ) ).toEqual( emptySeed );
+ } );
+
+ test( "should drop non-numeric and non-positive ids and never let the total undercut them", () => {
+ const getPreselectionState = requireGetPreselectionState();
+
+ expect( getPreselectionState( { contentType: "post", postIds: [ "5", 3, 0, -2, "junk" ], selectedCount: 1 } ) ).toEqual( {
+ activeContentType: "post",
+ selection: { selectedIds: [ 5, 3 ], preselectedTotal: 2 },
+ query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true },
+ } );
+ } );
+
+ test( "should cap the seeded ids at the batch size", () => {
+ const getPreselectionState = requireGetPreselectionState();
+
+ const postIds = Array.from( { length: 25 }, ( _, index ) => index + 1 );
+ const { selection } = getPreselectionState( { contentType: "post", postIds, selectedCount: 25 } );
+
+ expect( selection.selectedIds ).toHaveLength( 20 );
+ expect( selection.preselectedTotal ).toBe( 25 );
+ } );
+
+ test( "should not report a carried-over total without any usable ids", () => {
+ const getPreselectionState = requireGetPreselectionState();
+
+ expect( getPreselectionState( { contentType: "post", postIds: [], selectedCount: 25 } ) ).toEqual( {
+ activeContentType: "post",
+ selection: { selectedIds: [], preselectedTotal: 0 },
+ query: { overviewIds: [], isOverviewFilterActive: false },
+ } );
+ } );
+ } );
+
test( "should construct the remote data provider with the REST nonce header", () => {
const root = document.createElement( "div" );
root.id = ROOT_ID;
diff --git a/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js
new file mode 100644
index 00000000000..81360b9063f
--- /dev/null
+++ b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js
@@ -0,0 +1,98 @@
+import { SlotFillProvider } from "@wordpress/components";
+import { OverviewExclusionNotice } from "../../src/bulk-editor/components/overview-exclusion-notice";
+import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content";
+import { DataProvider } from "../../src/bulk-editor/services";
+import registerStore from "../../src/bulk-editor/store";
+import { fireEvent, render, screen, within } from "../test-utils";
+
+const NOTICE_TEXT = "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded.";
+const TRUNCATION_TEXT = "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time.";
+
+describe( "OverviewExclusionNotice", () => {
+ it( "explains that carried-over items were excluded", () => {
+ render( );
+
+ expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument();
+ } );
+
+ it( "calls onDismiss when the dismiss button is clicked", () => {
+ const onDismiss = jest.fn();
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) );
+
+ expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( "renders nothing while nothing was excluded", () => {
+ const { container } = render( );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+} );
+
+describe( "BulkEditorContent with a truncated and pruned carried-over selection", () => {
+ const dataProvider = new DataProvider( {
+ contentTypes: [ { name: "post", label: "Posts", singularLabel: "Post" } ],
+ endpoints: { posts: "https://example.com/wp-json/yoast/v1/bulk_editor/posts" },
+ links: {},
+ } );
+ // The data layer is covered by use-posts.test.js; the request stays pending here.
+ const remoteDataProvider = { fetchJson: jest.fn( () => new Promise( () => {} ) ) };
+
+ // The store registers once for the whole file: the WP data registry is global, so a second register would
+ // clash. Seeded as if 25 items were selected on the overview and pruning dropped some of the carried 20.
+ beforeAll( () => {
+ registerStore( {
+ initialState: {
+ activeContentType: "post",
+ selection: {
+ selectedIds: Array.from( { length: 18 }, ( _, index ) => index + 1 ),
+ preselectedTotal: 25,
+ hasExcludedPreselected: true,
+ },
+ },
+ } );
+ } );
+
+ const renderContent = () => render(
+
+
+
+ );
+
+ it( "shows the truncation and exclusion notices together, dismissible independently", () => {
+ const { container } = renderContent();
+
+ const truncation = screen.getByText( TRUNCATION_TEXT );
+ const exclusion = screen.getByText( NOTICE_TEXT );
+ expect( truncation ).toBeInTheDocument();
+ expect( exclusion ).toBeInTheDocument();
+ // Both render inside the expanded bulk-actions row, the truncation notice first.
+ expect( exclusion.closest( "tr" ) ).toBe( truncation.closest( "tr" ) );
+ expect( truncation.closest( "tr" ) ).toHaveAttribute( "aria-hidden", "false" );
+ // eslint-disable-next-line no-bitwise -- compareDocumentPosition returns a bitmask.
+ expect( truncation.compareDocumentPosition( exclusion ) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy();
+
+ fireEvent.click( within( exclusion.closest( "[role='status']" ) ).getByRole( "button", { name: "Dismiss" } ) );
+
+ // The exclusion notice is gone; the truncation notice keeps the row expanded.
+ expect( screen.queryByText( NOTICE_TEXT ) ).not.toBeInTheDocument();
+ expect( screen.getByText( TRUNCATION_TEXT ) ).toBeInTheDocument();
+ expect( screen.getByText( TRUNCATION_TEXT ).closest( "tr" ) ).toHaveAttribute( "aria-hidden", "false" );
+
+ fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) );
+
+ // Nothing else occupies the band here (AI is disabled, no edits), so dismissing both collapses the row.
+ expect( screen.queryByText( TRUNCATION_TEXT ) ).not.toBeInTheDocument();
+ container.querySelectorAll( "tr[aria-hidden]" ).forEach( ( row ) => {
+ expect( row ).toHaveAttribute( "aria-hidden", "true" );
+ } );
+ } );
+} );
diff --git a/packages/js/tests/bulk-editor/overview-selection-notice.test.js b/packages/js/tests/bulk-editor/overview-selection-notice.test.js
new file mode 100644
index 00000000000..eb2ba6a3298
--- /dev/null
+++ b/packages/js/tests/bulk-editor/overview-selection-notice.test.js
@@ -0,0 +1,84 @@
+import { SlotFillProvider } from "@wordpress/components";
+import { OverviewSelectionNotice } from "../../src/bulk-editor/components/overview-selection-notice";
+import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content";
+import { DataProvider } from "../../src/bulk-editor/services";
+import registerStore from "../../src/bulk-editor/store";
+import { fireEvent, render, screen } from "../test-utils";
+
+const NOTICE_TEXT = "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time.";
+
+describe( "OverviewSelectionNotice", () => {
+ it( "explains that only the first batch of the overview selection is selected", () => {
+ render( );
+
+ expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument();
+ } );
+
+ it( "calls onDismiss when the dismiss button is clicked", () => {
+ const onDismiss = jest.fn();
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) );
+
+ expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( "renders nothing while the whole selection fits the batch", () => {
+ const { container } = render( );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+} );
+
+describe( "BulkEditorContent with a carried-over overview selection", () => {
+ const dataProvider = new DataProvider( {
+ contentTypes: [ { name: "post", label: "Posts", singularLabel: "Post" } ],
+ endpoints: { posts: "https://example.com/wp-json/yoast/v1/bulk_editor/posts" },
+ links: {},
+ } );
+ // The data layer is covered by use-posts.test.js; the request stays pending here.
+ const remoteDataProvider = { fetchJson: jest.fn( () => new Promise( () => {} ) ) };
+
+ // The store registers once for the whole file: the WP data registry is global, so a second register would
+ // clash. Seeded like initialize.js does for a selection carried over from the WP admin overview.
+ beforeAll( () => {
+ registerStore( {
+ initialState: {
+ activeContentType: "post",
+ selection: { selectedIds: Array.from( { length: 20 }, ( _, index ) => index + 1 ), preselectedTotal: 25 },
+ },
+ } );
+ } );
+
+ const renderContent = () => render(
+
+
+
+ );
+
+ it( "shows the notice inside the expanded bulk-actions row and hides it on dismiss", () => {
+ const { container } = renderContent();
+
+ const notice = screen.getByText( NOTICE_TEXT );
+ expect( notice ).toBeInTheDocument();
+ // The notice renders inside the table's bulk-actions row (between the Select toolbar and the band),
+ // which the notice itself keeps expanded.
+ const bandRow = notice.closest( "tr" );
+ expect( bandRow ).not.toBeNull();
+ expect( bandRow ).toHaveAttribute( "aria-hidden", "false" );
+
+ fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) );
+
+ expect( screen.queryByText( NOTICE_TEXT ) ).not.toBeInTheDocument();
+ // Nothing else occupies the band here (AI is disabled, no edits), so dismissing collapses the row.
+ container.querySelectorAll( "tr[aria-hidden]" ).forEach( ( row ) => {
+ expect( row ).toHaveAttribute( "aria-hidden", "true" );
+ } );
+ } );
+} );
diff --git a/packages/js/tests/bulk-editor/store/query.test.js b/packages/js/tests/bulk-editor/store/query.test.js
index 1b45a6842b0..9d2178832ec 100644
--- a/packages/js/tests/bulk-editor/store/query.test.js
+++ b/packages/js/tests/bulk-editor/store/query.test.js
@@ -2,8 +2,8 @@ import { activeContentTypeActions } from "../../../src/bulk-editor/store/active-
import reducer, { createInitialQueryState, queryActions, querySelectors } from "../../../src/bulk-editor/store/query";
describe( "query slice", () => {
- it( "defaults to an empty search on the first page with no status or needs-improvement filter", () => {
- expect( createInitialQueryState() ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [] } );
+ it( "defaults to an empty search on the first page with no status, needs-improvement or overview filter", () => {
+ expect( createInitialQueryState() ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [], overviewIds: [], isOverviewFilterActive: false } );
} );
it( "sets the search term and resets to the first page", () => {
@@ -30,10 +30,30 @@ describe( "query slice", () => {
expect( state ).toEqual( { search: "seo", page: 1, needsImprovement: [ "title", "description" ] } );
} );
- it( "resets to the first page when the content type changes", () => {
- const state = reducer( { search: "seo", page: 9 }, activeContentTypeActions.setActiveContentType( "page" ) );
+ it( "resets to the first page and drops the overview selection when the content type changes", () => {
+ const state = reducer(
+ { search: "seo", page: 9, overviewIds: [ 5, 3 ], isOverviewFilterActive: true },
+ activeContentTypeActions.setActiveContentType( "page" )
+ );
- expect( state ).toEqual( { search: "seo", page: 1 } );
+ expect( state ).toEqual( { search: "seo", page: 1, overviewIds: [], isOverviewFilterActive: false } );
+ } );
+
+ it( "toggles the overview filter and resets to the first page", () => {
+ let state = reducer( { page: 4, overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, queryActions.setOverviewFilterActive( false ) );
+ expect( state ).toEqual( { page: 1, overviewIds: [ 5, 3 ], isOverviewFilterActive: false } );
+
+ state = reducer( { ...state, page: 2 }, queryActions.setOverviewFilterActive( true ) );
+ expect( state ).toEqual( { page: 1, overviewIds: [ 5, 3 ], isOverviewFilterActive: true } );
+ } );
+
+ it( "selects the overview selection state, defaulting when missing", () => {
+ const state = { query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true } };
+
+ expect( querySelectors.selectOverviewIds( state ) ).toEqual( [ 5, 3 ] );
+ expect( querySelectors.selectIsOverviewFilterActive( state ) ).toBe( true );
+ expect( querySelectors.selectOverviewIds( {} ) ).toEqual( [] );
+ expect( querySelectors.selectIsOverviewFilterActive( {} ) ).toBe( false );
} );
it( "selects the search term, page, statuses, needs-improvement and query from the store state", () => {
@@ -51,6 +71,6 @@ describe( "query slice", () => {
expect( querySelectors.selectPage( {} ) ).toBe( 1 );
expect( querySelectors.selectStatuses( {} ) ).toEqual( [] );
expect( querySelectors.selectNeedsImprovement( {} ) ).toEqual( [] );
- expect( querySelectors.selectQuery( {} ) ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [] } );
+ expect( querySelectors.selectQuery( {} ) ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [], overviewIds: [], isOverviewFilterActive: false } );
} );
} );
diff --git a/packages/js/tests/bulk-editor/store/selection.test.js b/packages/js/tests/bulk-editor/store/selection.test.js
index 9cc052550e5..e82a47ad24a 100644
--- a/packages/js/tests/bulk-editor/store/selection.test.js
+++ b/packages/js/tests/bulk-editor/store/selection.test.js
@@ -4,8 +4,8 @@ import reducer, { createInitialSelectionState, selectionActions, selectionSelect
import { queryActions } from "../../../src/bulk-editor/store/query";
describe( "selection slice", () => {
- it( "defaults to no rows selected", () => {
- expect( createInitialSelectionState() ).toEqual( { selectedIds: [] } );
+ it( "defaults to no rows selected, no carried-over selection and no exclusions", () => {
+ expect( createInitialSelectionState() ).toEqual( { selectedIds: [], preselectedTotal: 0, hasExcludedPreselected: false } );
} );
it( "adds a row to the selection when toggled on", () => {
@@ -39,6 +39,47 @@ describe( "selection slice", () => {
expect( state.selectedIds ).toEqual( [] );
} );
+ it( "prunes selected ids missing from the given selectable ids, keeping the carried-over selection total and flagging the exclusion", () => {
+ const state = reducer( { selectedIds: [ 7, 9, 11 ], preselectedTotal: 25 }, selectionActions.pruneSelection( [ 9, 11, 13 ] ) );
+
+ expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: true } );
+ } );
+
+ it( "keeps the selection intact and unflagged when all selected ids are selectable", () => {
+ const state = reducer(
+ { selectedIds: [ 7, 9 ], preselectedTotal: 25, hasExcludedPreselected: false },
+ selectionActions.pruneSelection( [ 7, 9, 11 ] )
+ );
+
+ expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25, hasExcludedPreselected: false } );
+ } );
+
+ it( "clears the exclusion flag on dismissExclusionNotice, keeping the selection and the carried-over total", () => {
+ const state = reducer(
+ { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: true },
+ selectionActions.dismissExclusionNotice()
+ );
+
+ expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: false } );
+ } );
+
+ it( "clears the exclusion flag on selectAll", () => {
+ const state = reducer( { selectedIds: [ 9 ], preselectedTotal: 25, hasExcludedPreselected: true }, selectionActions.selectAll( [ 7, 9 ] ) );
+
+ expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0, hasExcludedPreselected: false } );
+ } );
+
+ it( "clears the exclusion flag when the shown result set changes", () => {
+ const state = reducer( { selectedIds: [ 9 ], preselectedTotal: 25, hasExcludedPreselected: true }, queryActions.setPage( 2 ) );
+
+ expect( state ).toEqual( createInitialSelectionState() );
+ } );
+
+ it( "selects the exclusion flag, defaulting to false when missing", () => {
+ expect( selectionSelectors.selectHasExcludedPreselected( { selection: { hasExcludedPreselected: true } } ) ).toBe( true );
+ expect( selectionSelectors.selectHasExcludedPreselected( {} ) ).toBe( false );
+ } );
+
it( "clears the selection when the status filter changes", () => {
const state = reducer( { selectedIds: [ 7, 9 ] }, queryActions.setStatuses( [ "draft" ] ) );
@@ -79,4 +120,45 @@ describe( "selection slice", () => {
expect( selectionSelectors.selectSelectedIds( { selection: { selectedIds: [ 7 ] } } ) ).toEqual( [ 7 ] );
expect( selectionSelectors.selectSelectedIds( {} ) ).toEqual( [] );
} );
+
+ it( "clears the carried-over selection total on dismissPreselectionNotice", () => {
+ const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, selectionActions.dismissPreselectionNotice() );
+
+ expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0 } );
+ } );
+
+ it( "clears the carried-over selection total on selectAll", () => {
+ const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.selectAll( [ 7, 9 ] ) );
+
+ expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0, hasExcludedPreselected: false } );
+ } );
+
+ it( "clears the carried-over selection total on deselectAll", () => {
+ const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.deselectAll() );
+
+ expect( state ).toEqual( createInitialSelectionState() );
+ } );
+
+ it( "keeps the carried-over selection total when a single row is toggled", () => {
+ const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.toggleRow( 9 ) );
+
+ expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25 } );
+ } );
+
+ it( "clears the carried-over selection total when the shown result set changes", () => {
+ const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setPage( 2 ) );
+
+ expect( state ).toEqual( createInitialSelectionState() );
+ } );
+
+ it( "clears the selection when the overview filter is toggled", () => {
+ const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setOverviewFilterActive( false ) );
+
+ expect( state ).toEqual( createInitialSelectionState() );
+ } );
+
+ it( "selects the carried-over selection total, defaulting to 0 when missing", () => {
+ expect( selectionSelectors.selectPreselectedTotal( { selection: { preselectedTotal: 25 } } ) ).toBe( 25 );
+ expect( selectionSelectors.selectPreselectedTotal( {} ) ).toBe( 0 );
+ } );
} );
diff --git a/src/bulk-editor/domain/posts/posts-query.php b/src/bulk-editor/domain/posts/posts-query.php
index a3b6f70c541..0ab8b8bf6cc 100644
--- a/src/bulk-editor/domain/posts/posts-query.php
+++ b/src/bulk-editor/domain/posts/posts-query.php
@@ -69,6 +69,13 @@ class Posts_Query {
*/
private $scores_enabled;
+ /**
+ * The post IDs to limit posts to, or an empty array for no restriction.
+ *
+ * @var array
+ */
+ private $include_ids;
+
/**
* The constructor.
*
@@ -80,6 +87,7 @@ class Posts_Query {
* @param int|null $author_id The author to limit posts to, or null for no author restriction.
* @param array $needs_improvement The needs-improvement fields to filter by (a field matches when empty, or — for search fields with SEO analysis enabled — when its per-field score needs improvement), or an empty array for no such filter.
* @param bool $scores_enabled Whether the per-field scores may back the needs-improvement filter.
+ * @param array $include_ids The post IDs to limit posts to, or an empty array for no restriction.
*/
public function __construct(
string $content_type,
@@ -89,7 +97,8 @@ public function __construct(
array $statuses,
?int $author_id = null,
array $needs_improvement = [],
- bool $scores_enabled = true
+ bool $scores_enabled = true,
+ array $include_ids = []
) {
$this->content_type = $content_type;
$this->page = $page;
@@ -99,6 +108,7 @@ public function __construct(
$this->author_id = $author_id;
$this->needs_improvement = $needs_improvement;
$this->scores_enabled = $scores_enabled;
+ $this->include_ids = $include_ids;
}
/**
@@ -191,6 +201,24 @@ public function has_author_filter(): bool {
return $this->author_id !== null;
}
+ /**
+ * Returns the post IDs to limit posts to.
+ *
+ * @return array The post IDs, or an empty array for no restriction.
+ */
+ public function get_include_ids(): array {
+ return $this->include_ids;
+ }
+
+ /**
+ * Returns whether posts are limited to specific post IDs.
+ *
+ * @return bool Whether a post ID restriction is set.
+ */
+ public function has_include(): bool {
+ return $this->include_ids !== [];
+ }
+
/**
* Returns the zero-based offset of the requested page.
*
diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
index c3c5675a076..79497d69785 100644
--- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
+++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
@@ -153,6 +153,10 @@ private function build_query( Posts_Query $query ): ORM {
$builder->where( 'author_id', $query->get_author_id() );
}
+ if ( $query->has_include() ) {
+ $builder->where_in( 'object_id', $query->get_include_ids() );
+ }
+
if ( $query->has_search() ) {
$this->apply_search( $builder, $query->get_search() );
}
diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
index fd0c1b8da2c..12fe1d00459 100644
--- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
+++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
@@ -147,7 +147,7 @@ protected function run_query( Posts_Query $query ): WP_Query {
*
* @param Posts_Query $query The query describing the page to collect.
*
- * @return array> The WP_Query arguments.
+ * @return array|array> The WP_Query arguments.
*/
private function build_query_args( Posts_Query $query ): array {
$args = [
@@ -170,6 +170,10 @@ private function build_query_args( Posts_Query $query ): array {
$args['author'] = $query->get_author_id();
}
+ if ( $query->has_include() ) {
+ $args['post__in'] = $query->get_include_ids();
+ }
+
return $args;
}
diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php
index 4c745c04a70..0dc856929a0 100644
--- a/src/bulk-editor/user-interface/bulk-editor-integration.php
+++ b/src/bulk-editor/user-interface/bulk-editor-integration.php
@@ -6,6 +6,7 @@
use WPSEO_Admin_Asset_Manager;
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
+use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository;
use Yoast\WP\SEO\Conditionals\Admin_Conditional;
use Yoast\WP\SEO\General\User_Interface\General_Page_Integration;
@@ -31,6 +32,21 @@ class Bulk_Editor_Integration implements Integration_Interface {
*/
public const ASSETS_NAME = 'bulk-editor-page';
+ /**
+ * The URL parameter carrying the content type to preselect.
+ */
+ public const CONTENT_TYPE_PARAM = 'content_type';
+
+ /**
+ * The URL parameter carrying the post IDs to preselect, comma-separated.
+ */
+ public const POST_IDS_PARAM = 'post_ids';
+
+ /**
+ * The URL parameter carrying how many posts were selected on the overview the user came from.
+ */
+ public const SELECTED_COUNT_PARAM = 'selected_count';
+
/**
* Holds the WPSEO_Admin_Asset_Manager.
*
@@ -155,6 +171,7 @@ public function register_hooks() {
if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) {
\add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
\add_action( 'in_admin_header', [ $this, 'remove_notices' ], \PHP_INT_MAX );
+ \add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] );
}
}
@@ -220,8 +237,10 @@ public function enqueue_assets() {
* @return array>> The script data.
*/
public function get_script_data() {
+ $content_types = $this->content_types_repository->get_content_types();
+
return [
- 'contentTypes' => $this->content_types_repository->get_content_types(),
+ 'contentTypes' => $content_types,
'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(),
// These must stay server-generated URLs: the bulk editor assigns them to window.location.href for its
// "Back to Tools" / logo navigation. If a link ever derives from request input, validate it with
@@ -244,10 +263,88 @@ public function get_script_data() {
// Re-scoring only runs when SEO analysis is enabled, matching the post editor.
'keywordAnalysisActive' => $this->options_helper->get( 'keyword_analysis_active' ) === true,
],
+ 'initialSelection' => $this->get_initial_selection( $content_types ),
'myyoastConnection' => $this->myyoast_connection_data_presenter->present(),
];
}
+ /**
+ * Returns the selection carried over from a post overview bulk action, if any.
+ *
+ * The parameters only decide which rows start out selected in the app; the REST endpoints
+ * enforce the actual per-post edit access when anything is saved.
+ *
+ * @param array> $content_types The available content types.
+ *
+ * @return array> The content type, post IDs and overview selection count.
+ */
+ private function get_initial_selection( array $content_types ): array {
+ $initial_selection = [
+ 'contentType' => '',
+ 'postIds' => [],
+ 'selectedCount' => 0,
+ ];
+
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reason: read-only display state, no action is taken.
+ if ( ! isset( $_GET[ self::CONTENT_TYPE_PARAM ] ) || ! \is_string( $_GET[ self::CONTENT_TYPE_PARAM ] ) ) {
+ return $initial_selection;
+ }
+
+ $content_type = \sanitize_text_field( \wp_unslash( $_GET[ self::CONTENT_TYPE_PARAM ] ) );
+ if ( ! \in_array( $content_type, \array_column( $content_types, 'name' ), true ) ) {
+ return $initial_selection;
+ }
+ $initial_selection['contentType'] = $content_type;
+
+ if ( isset( $_GET[ self::POST_IDS_PARAM ] ) && \is_string( $_GET[ self::POST_IDS_PARAM ] ) ) {
+ $post_ids = \explode( ',', \sanitize_text_field( \wp_unslash( $_GET[ self::POST_IDS_PARAM ] ) ) );
+ $post_ids = \array_values(
+ \array_unique(
+ \array_filter(
+ \array_map( 'intval', $post_ids ),
+ static function ( $id ) {
+ return $id > 0;
+ },
+ ),
+ ),
+ );
+ $post_ids = \array_slice( $post_ids, 0, Batch_Limit::MAX_ITEMS );
+
+ $initial_selection['postIds'] = $post_ids;
+ $initial_selection['selectedCount'] = \count( $post_ids );
+ }
+
+ if (
+ $initial_selection['postIds'] !== []
+ && isset( $_GET[ self::SELECTED_COUNT_PARAM ] )
+ && \is_string( $_GET[ self::SELECTED_COUNT_PARAM ] )
+ ) {
+ // The count can only grow beyond the carried IDs, never shrink below them.
+ $initial_selection['selectedCount'] = \max(
+ $initial_selection['selectedCount'],
+ \absint( \wp_unslash( $_GET[ self::SELECTED_COUNT_PARAM ] ) ),
+ );
+ }
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ return $initial_selection;
+ }
+
+ /**
+ * Registers the carried-over selection parameters as removable, so WordPress cleans them from the
+ * address bar once the page has picked them up.
+ *
+ * @param array $removable_query_args The removable query args.
+ *
+ * @return array The removable query args.
+ */
+ public function add_removable_query_args( $removable_query_args ) {
+ $removable_query_args[] = self::POST_IDS_PARAM;
+ $removable_query_args[] = self::SELECTED_COUNT_PARAM;
+
+ return $removable_query_args;
+ }
+
/**
* Removes all current WP notices.
*
diff --git a/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php
new file mode 100644
index 00000000000..97207887b1c
--- /dev/null
+++ b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php
@@ -0,0 +1,161 @@
+content_types_repository = $content_types_repository;
+ $this->current_page_helper = $current_page_helper;
+ }
+
+ /**
+ * Returns the conditionals based on which this loadable should be active.
+ *
+ * @return array The conditionals.
+ */
+ public static function get_conditionals() {
+ return [ Admin_Conditional::class ];
+ }
+
+ /**
+ * Initializes the integration.
+ *
+ * This is the place to register hooks and filters.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ // The supported post types are only known once post types are registered, so defer collecting them.
+ \add_action( 'admin_init', [ $this, 'register_bulk_actions' ] );
+ }
+
+ /**
+ * Registers the bulk action on the overviews of the post types the bulk editor supports.
+ *
+ * @return void
+ */
+ public function register_bulk_actions() {
+ // The same capability that gates access to the bulk editor page itself.
+ if ( ! \current_user_can( 'wpseo_manage_options' ) ) {
+ return;
+ }
+
+ foreach ( $this->content_types_repository->get_content_types() as $content_type ) {
+ \add_filter( 'bulk_actions-edit-' . $content_type['name'], [ $this, 'add_bulk_action' ] );
+ \add_filter( 'handle_bulk_actions-edit-' . $content_type['name'], [ $this, 'handle_bulk_action' ], 10, 3 );
+ }
+ }
+
+ /**
+ * Adds the bulk editor entry to the bulk-actions dropdown.
+ *
+ * @param array> $actions The bulk actions.
+ *
+ * @return array> The bulk actions.
+ */
+ public function add_bulk_action( $actions ) {
+ // Trashed posts are not listed in the bulk editor, so the trash view does not get the entry.
+ if ( $this->current_page_helper->is_trash_overview() ) {
+ return $actions;
+ }
+
+ // The arrow signals that this action navigates to another page instead of processing in place.
+ // VS15 (U+FE0E) forces text presentation, so wp-admin's emoji script leaves the glyph alone;
+ // RTL admins get the mirrored glyph.
+ $arrow = ( \is_rtl() ) ? "\u{2196}\u{FE0E}" : "\u{2197}\u{FE0E}";
+
+ // A nested array renders as an optgroup (since WP 5.6): the default actions stay first, followed
+ // by a visually separated "Yoast SEO" group holding the entry.
+ $actions['Yoast SEO'] = [
+ self::BULK_ACTION => \__( 'Bulk edit', 'wordpress-seo' ) . ' ' . $arrow,
+ ];
+
+ return $actions;
+ }
+
+ /**
+ * Handles the bulk action by sending the user to the bulk editor page with the selection carried over.
+ *
+ * @param string $redirect_url The URL the overview would redirect to.
+ * @param string $action The bulk action being handled.
+ * @param array $post_ids The selected post IDs.
+ *
+ * @return string The URL to redirect to.
+ */
+ public function handle_bulk_action( $redirect_url, $action, $post_ids ) {
+ if ( $action !== self::BULK_ACTION ) {
+ return $redirect_url;
+ }
+
+ // The filter only fires on the overview of the post type it was registered for, so the
+ // current screen carries the content type to preselect.
+ $screen = \get_current_screen();
+ if ( $screen === null || $screen->post_type === '' ) {
+ return $redirect_url;
+ }
+
+ $args = [
+ 'page' => Bulk_Editor_Integration::PAGE,
+ Bulk_Editor_Integration::CONTENT_TYPE_PARAM => $screen->post_type,
+ ];
+
+ $ids = \array_values(
+ \array_unique(
+ \array_filter(
+ \array_map( 'intval', (array) $post_ids ),
+ static function ( $id ) {
+ return $id > 0;
+ },
+ ),
+ ),
+ );
+
+ if ( $ids !== [] ) {
+ // Only the first batch can end up selected; carrying more would only risk hitting URL length limits.
+ $args[ Bulk_Editor_Integration::POST_IDS_PARAM ] = \implode( ',', \array_slice( $ids, 0, Batch_Limit::MAX_ITEMS ) );
+ $args[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = \count( $ids );
+ }
+
+ return \add_query_arg( $args, \admin_url( 'admin.php' ) );
+ }
+}
diff --git a/src/bulk-editor/user-interface/posts-route.php b/src/bulk-editor/user-interface/posts-route.php
index 3d91c0d3231..d87f27f1728 100644
--- a/src/bulk-editor/user-interface/posts-route.php
+++ b/src/bulk-editor/user-interface/posts-route.php
@@ -172,6 +172,17 @@ public function register_routes() {
],
'description' => 'The fields to filter posts by; a field matches when it is empty, or (for search fields with SEO analysis enabled) when its score needs improvement.',
],
+ 'include' => [
+ 'required' => false,
+ 'type' => 'array',
+ 'default' => [],
+ 'maxItems' => self::MAX_PER_PAGE,
+ 'items' => [
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ],
+ 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.',
+ ],
],
'callback' => [ $this, 'get_posts' ],
'permission_callback' => [ $this, 'check_permissions' ],
@@ -214,6 +225,9 @@ public function get_posts( WP_REST_Request $request ) {
// the filter falls back to the empty-field check.
$scores_enabled = $this->options_helper->get( 'keyword_analysis_active' ) === true;
+ // The schema already coerces the items to positive integers; deduplicate on top of that.
+ $include = \array_values( \array_unique( \array_map( 'intval', (array) $request->get_param( 'include' ) ) ) );
+
$query = new Posts_Query(
$content_type,
(int) $request->get_param( 'page' ),
@@ -223,6 +237,7 @@ public function get_posts( WP_REST_Request $request ) {
$author_id,
(array) $request->get_param( 'needs_improvement' ),
$scores_enabled,
+ $include,
);
// Posts the current user cannot edit are returned locked and without their SEO data; the per-post
diff --git a/src/helpers/current-page-helper.php b/src/helpers/current-page-helper.php
index 39e632fee47..f647a666294 100644
--- a/src/helpers/current-page-helper.php
+++ b/src/helpers/current-page-helper.php
@@ -429,6 +429,18 @@ public function get_current_yoast_seo_page() {
return '';
}
+ /**
+ * Returns whether the current admin overview (list table) shows the trash.
+ *
+ * Mirrors the check WP_Posts_List_Table itself uses to decide it is on the trash view.
+ *
+ * @return bool Whether the current admin overview shows the trash.
+ */
+ public function is_trash_overview(): bool {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
+ return isset( $_REQUEST['post_status'] ) && $_REQUEST['post_status'] === 'trash';
+ }
+
/**
* Checks if the current global post is the privacy policy page.
*
diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php
index 2a131fbd7ae..7fcda2ca9a2 100644
--- a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php
+++ b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php
@@ -98,4 +98,32 @@ public function test_has_author_filter() {
$this->assertTrue( ( new Posts_Query( 'page', 1, 20, '', [], 5 ) )->has_author_filter() );
$this->assertFalse( ( new Posts_Query( 'page', 1, 20, '', [] ) )->has_author_filter() );
}
+
+ /**
+ * Tests that the included post IDs default to no restriction.
+ *
+ * @return void
+ */
+ public function test_get_include_defaults_to_empty() {
+ $this->assertSame( [], ( new Posts_Query( 'page', 1, 20, '', [] ) )->get_include_ids() );
+ }
+
+ /**
+ * Tests that the included post IDs are carried through when set.
+ *
+ * @return void
+ */
+ public function test_get_include() {
+ $this->assertSame( [ 5, 3 ], ( new Posts_Query( 'page', 1, 20, '', [], null, [], true, [ 5, 3 ] ) )->get_include_ids() );
+ }
+
+ /**
+ * Tests that has_include reflects whether a post ID restriction is set.
+ *
+ * @return void
+ */
+ public function test_has_include() {
+ $this->assertTrue( ( new Posts_Query( 'page', 1, 20, '', [], null, [], true, [ 5, 3 ] ) )->has_include() );
+ $this->assertFalse( ( new Posts_Query( 'page', 1, 20, '', [] ) )->has_include() );
+ }
}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php
index 2b390ee20aa..fab5bbf8758 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php
@@ -253,6 +253,39 @@ public function test_get_posts_with_search() {
$this->assertSame( 0, $result['total'] );
}
+ /**
+ * Tests that a post ID restriction narrows the query to those posts.
+ *
+ * @return void
+ */
+ public function test_get_posts_restricts_to_the_included_post_ids() {
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 5;
+
+ $query = Mockery::mock( ORM::class );
+ $query->allows( 'where' )->andReturnSelf();
+ $query->expects( 'where_in' )->with( 'post_status', self::STATUSES )->andReturnSelf();
+ $query->expects( 'where_in' )->with( 'object_id', [ 5, 3 ] )->andReturnSelf();
+ $query->allows( 'order_by_desc' )->andReturnSelf();
+ $query->allows( 'limit' )->andReturnSelf();
+ $query->allows( 'offset' )->andReturnSelf();
+ // The page is not full, so the total is derived and build_query runs only once.
+ $query->expects( 'find_many' )->once()->andReturn( [ $indexable ] );
+ $query->expects( 'count' )->never();
+
+ $this->indexable_repository->allows( 'query' )->andReturn( $query );
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 5 ] )->andReturn( [ 5 => true ] );
+
+ Functions\expect( 'get_the_title' )->once()->with( 5 )->andReturn( 'Hello world' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 5, 'raw' )->andReturn( 'edit' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [], true, [ 5, 3 ] ) )->to_array();
+
+ $this->assertSame( 5, $result['posts'][0]['id'] );
+ $this->assertSame( 1, $result['total'] );
+ }
+
/**
* Stubs the indexable query for a page that returns the given rows, without constraining count().
*
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php
new file mode 100644
index 00000000000..96aed9e9353
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php
@@ -0,0 +1,68 @@
+
+ */
+ private const STATUSES = [ 'publish', 'draft', 'pending', 'future' ];
+
+ /**
+ * Tests that the included post IDs restrict the query through post__in.
+ *
+ * @return void
+ */
+ public function test_build_query_args_restricts_to_the_included_post_ids() {
+ $args = $this->invoke_build_query_args( new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [], true, [ 5, 3 ] ) );
+
+ $this->assertSame( [ 5, 3 ], $args['post__in'] );
+ }
+
+ /**
+ * Tests that post__in is left out when no post IDs are included.
+ *
+ * @return void
+ */
+ public function test_build_query_args_without_included_post_ids() {
+ $args = $this->invoke_build_query_args( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) );
+
+ $this->assertArrayNotHasKey( 'post__in', $args );
+ }
+
+ /**
+ * Invokes the private build_query_args on a real collector instance.
+ *
+ * The construction of the WP_Query consuming these arguments cannot be intercepted (the class name is
+ * already declared as a plain mock elsewhere in the suite, which rules out an overload mock), so the
+ * built arguments are asserted directly instead.
+ *
+ * @param Posts_Query $query The query describing the page to collect.
+ *
+ * @return array|array> The built WP_Query arguments.
+ */
+ private function invoke_build_query_args( Posts_Query $query ): array {
+ $instance = new Post_Meta_Posts_Collector( $this->post_editability_resolver );
+
+ $reflection = new ReflectionMethod( $instance, 'build_query_args' );
+ $reflection->setAccessible( true );
+
+ return $reflection->invoke( $instance, $query );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php
new file mode 100644
index 00000000000..2dd3830b9c3
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php
@@ -0,0 +1,33 @@
+assertSame(
+ [
+ 'settings-updated',
+ Bulk_Editor_Integration::POST_IDS_PARAM,
+ Bulk_Editor_Integration::SELECTED_COUNT_PARAM,
+ ],
+ $this->instance->add_removable_query_args( [ 'settings-updated' ] ),
+ );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php
index 80036187406..a7dfea59a01 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php
@@ -58,6 +58,11 @@ public function test_enqueue_assets() {
'contentLocale' => 'en_US',
'keywordAnalysisActive' => true,
],
+ 'initialSelection' => [
+ 'contentType' => '',
+ 'postIds' => [],
+ 'selectedCount' => 0,
+ ],
'myyoastConnection' => null,
];
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php
new file mode 100644
index 00000000000..7389b4ef05d
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php
@@ -0,0 +1,215 @@
+assertSame(
+ [
+ 'contentType' => '',
+ 'postIds' => [],
+ 'selectedCount' => 0,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Tests that a supported content type is carried over, also without post IDs.
+ *
+ * @return void
+ */
+ public function test_carries_a_supported_content_type() {
+ $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post';
+
+ $this->assertSame(
+ [
+ 'contentType' => 'post',
+ 'postIds' => [],
+ 'selectedCount' => 0,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Tests that an unsupported content type drops the whole selection.
+ *
+ * @return void
+ */
+ public function test_ignores_an_unsupported_content_type() {
+ $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'unsupported';
+ $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3';
+
+ $this->assertSame(
+ [
+ 'contentType' => '',
+ 'postIds' => [],
+ 'selectedCount' => 0,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Tests that duplicate, non-numeric, zero and negative post IDs are dropped.
+ *
+ * @return void
+ */
+ public function test_sanitizes_the_post_ids() {
+ $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post';
+ $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '5,3,3,0,-2,junk';
+
+ $this->assertSame(
+ [
+ 'contentType' => 'post',
+ 'postIds' => [ 5, 3 ],
+ 'selectedCount' => 2,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Tests that the post IDs are capped at the batch limit while the count keeps the overview's total.
+ *
+ * @return void
+ */
+ public function test_caps_the_post_ids_at_the_batch_limit() {
+ $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post';
+ $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = \implode( ',', \range( 1, 25 ) );
+ $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = '30';
+
+ $this->assertSame(
+ [
+ 'contentType' => 'post',
+ 'postIds' => \range( 1, 20 ),
+ 'selectedCount' => 30,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Tests that the selected count can never be lower than the number of carried post IDs.
+ *
+ * @return void
+ */
+ public function test_selected_count_never_undercuts_the_post_ids() {
+ $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post';
+ $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3';
+ $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = '1';
+
+ $this->assertSame(
+ [
+ 'contentType' => 'post',
+ 'postIds' => [ 1, 2, 3 ],
+ 'selectedCount' => 3,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Tests that a non-scalar selected count is ignored.
+ *
+ * @return void
+ */
+ public function test_ignores_a_non_scalar_selected_count() {
+ $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post';
+ $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3';
+ $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = [ '25' ];
+
+ $this->assertSame(
+ [
+ 'contentType' => 'post',
+ 'postIds' => [ 1, 2, 3 ],
+ 'selectedCount' => 3,
+ ],
+ $this->get_initial_selection(),
+ );
+ }
+
+ /**
+ * Primes every collaborator the script data needs and returns the initial selection part.
+ *
+ * @return array> The initial selection script data.
+ */
+ private function get_initial_selection() {
+ $this->stubEscapeFunctions();
+ Functions\stubs(
+ [
+ 'rest_url' => 'https://example.com/wp-json/',
+ 'is_rtl' => false,
+ 'get_locale' => 'en_US',
+ 'plugins_url' => 'https://example.com/wp-content/plugins/wordpress-seo',
+ 'admin_url' => static function ( $path ) {
+ return 'https://example.com/wp-admin/' . $path;
+ },
+ 'wp_unslash' => static function ( $value ) {
+ return $value;
+ },
+ 'sanitize_text_field' => static function ( $value ) {
+ return $value;
+ },
+ ],
+ );
+
+ $endpoint_list = Mockery::mock( Endpoint_List::class );
+ $endpoint_list->allows( 'to_array' )->andReturn( [] );
+
+ $this->content_types_repository->allows( 'get_content_types' )->andReturn(
+ [
+ [
+ 'name' => 'post',
+ 'label' => 'Posts',
+ 'singularLabel' => 'Post',
+ ],
+ ],
+ );
+ $this->endpoints_repository->allows( 'get_all_endpoints' )->andReturn( $endpoint_list );
+ $this->nonce_repository->allows( 'get_rest_nonce' )->andReturn( 'rest-nonce' );
+ $this->product_helper->allows( 'is_premium' )->andReturn( false );
+ $this->options_helper->allows( 'get' )->andReturn( true );
+ $this->short_link_helper->allows( 'get_query_params' )->andReturn( [] );
+ $this->myyoast_connection_data_presenter->allows( 'present' )->andReturn( null );
+
+ return $this->instance->get_script_data()['initialSelection'];
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php
index 9ef1633a884..e0799e5df8b 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php
@@ -33,6 +33,7 @@ public function test_register_hooks_not_on_bulk_editor_page() {
Actions\expectAdded( 'admin_head' )->once()->with( [ $this->instance, 'remove_menu_item' ] );
Actions\expectAdded( 'admin_enqueue_scripts' )->never();
Actions\expectAdded( 'in_admin_header' )->never();
+ Filters\expectAdded( 'removable_query_args' )->never();
$this->instance->register_hooks();
}
@@ -53,6 +54,7 @@ public function test_register_hooks_on_bulk_editor_page() {
Actions\expectAdded( 'admin_head' )->once()->with( [ $this->instance, 'remove_menu_item' ] );
Actions\expectAdded( 'admin_enqueue_scripts' )->once()->with( [ $this->instance, 'enqueue_assets' ] );
Actions\expectAdded( 'in_admin_header' )->once()->with( [ $this->instance, 'remove_notices' ], \PHP_INT_MAX );
+ Filters\expectAdded( 'removable_query_args' )->once()->with( [ $this->instance, 'add_removable_query_args' ] );
$this->instance->register_hooks();
}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php
new file mode 100644
index 00000000000..bd931a2339f
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php
@@ -0,0 +1,94 @@
+content_types_repository = Mockery::mock( Content_Types_Repository::class );
+ $this->current_page_helper = Mockery::mock( Current_Page_Helper::class );
+
+ $this->instance = new Posts_Overview_Bulk_Actions_Integration(
+ $this->content_types_repository,
+ $this->current_page_helper,
+ );
+ }
+
+ /**
+ * Creates a WP_Screen mock for a post overview screen.
+ *
+ * @param string $post_type The screen post type.
+ *
+ * @return Mockery\MockInterface|WP_Screen The screen mock.
+ */
+ protected function mock_screen( $post_type = 'post' ) {
+ $screen = Mockery::mock( 'WP_Screen' );
+ $screen->post_type = $post_type;
+
+ return $screen;
+ }
+
+ /**
+ * Creates the content types repository representation of the given post types.
+ *
+ * @param array $post_types The post type names.
+ *
+ * @return array> The content types.
+ */
+ protected function content_types_for( array $post_types ) {
+ $content_types = [];
+ foreach ( $post_types as $post_type ) {
+ $content_types[] = [
+ 'name' => $post_type,
+ 'label' => \ucfirst( $post_type ) . 's',
+ 'singularLabel' => \ucfirst( $post_type ),
+ ];
+ }
+
+ return $content_types;
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php
new file mode 100644
index 00000000000..2466133ac5e
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php
@@ -0,0 +1,73 @@
+stubTranslationFunctions();
+ Functions\when( 'is_rtl' )->justReturn( false );
+ $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( false );
+
+ $actions = [ 'edit' => 'Edit' ];
+
+ $this->assertSame(
+ [
+ 'edit' => 'Edit',
+ 'Yoast SEO' => [
+ Posts_Overview_Bulk_Actions_Integration::BULK_ACTION => "Bulk edit \u{2197}\u{FE0E}",
+ ],
+ ],
+ $this->instance->add_bulk_action( $actions ),
+ );
+ }
+
+ /**
+ * Tests that the navigation arrow is mirrored on RTL admins.
+ *
+ * @return void
+ */
+ public function test_mirrors_the_arrow_on_rtl() {
+ $this->stubTranslationFunctions();
+ Functions\when( 'is_rtl' )->justReturn( true );
+ $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( false );
+
+ $actions = $this->instance->add_bulk_action( [] );
+
+ $this->assertSame(
+ "Bulk edit \u{2196}\u{FE0E}",
+ $actions['Yoast SEO'][ Posts_Overview_Bulk_Actions_Integration::BULK_ACTION ],
+ );
+ }
+
+ /**
+ * Tests that the entry is not added on the trash view.
+ *
+ * @return void
+ */
+ public function test_does_not_add_the_bulk_action_on_the_trash_view() {
+ $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( true );
+
+ $actions = [ 'untrash' => 'Restore' ];
+
+ $this->assertSame( $actions, $this->instance->add_bulk_action( $actions ) );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php
new file mode 100644
index 00000000000..8f51757e5c6
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php
@@ -0,0 +1,34 @@
+assertInstanceOf(
+ Content_Types_Repository::class,
+ $this->getPropertyValue( $this->instance, 'content_types_repository' ),
+ );
+ $this->assertInstanceOf(
+ Current_Page_Helper::class,
+ $this->getPropertyValue( $this->instance, 'current_page_helper' ),
+ );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php
new file mode 100644
index 00000000000..ec7579d1f4d
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php
@@ -0,0 +1,27 @@
+assertSame( [ Admin_Conditional::class ], Posts_Overview_Bulk_Actions_Integration::get_conditionals() );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php
new file mode 100644
index 00000000000..46e0516deda
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php
@@ -0,0 +1,140 @@
+assertSame(
+ 'https://example.com/wp-admin/edit.php',
+ $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', 'trash', [ 1, 2 ] ),
+ );
+ }
+
+ /**
+ * Tests that without a current screen the redirect URL is left untouched.
+ *
+ * @return void
+ */
+ public function test_leaves_the_redirect_alone_without_a_screen() {
+ Functions\expect( 'get_current_screen' )->once()->andReturn( null );
+
+ $this->assertSame(
+ 'https://example.com/wp-admin/edit.php',
+ $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, [ 1 ] ),
+ );
+ }
+
+ /**
+ * Tests that the action redirects to the bulk editor page with the selection carried over.
+ *
+ * @return void
+ */
+ public function test_redirects_to_the_bulk_editor_page_with_the_selection() {
+ Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'post' ) );
+
+ Functions\expect( 'admin_url' )
+ ->once()
+ ->with( 'admin.php' )
+ ->andReturn( 'https://example.com/wp-admin/admin.php' );
+ Functions\expect( 'add_query_arg' )
+ ->once()
+ ->with(
+ [
+ 'page' => 'wpseo_page_bulk_edit',
+ 'content_type' => 'post',
+ 'post_ids' => '5,3',
+ 'selected_count' => 2,
+ ],
+ 'https://example.com/wp-admin/admin.php',
+ )
+ ->andReturn( 'the-bulk-editor-url' );
+
+ // Duplicate, non-numeric, zero and negative IDs are dropped.
+ $post_ids = [ '5', 3, 3, 0, -2, 'junk' ];
+
+ $this->assertSame(
+ 'the-bulk-editor-url',
+ $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, $post_ids ),
+ );
+ }
+
+ /**
+ * Tests that only the first batch of IDs is carried while the count reflects the whole selection.
+ *
+ * @return void
+ */
+ public function test_truncates_the_carried_ids_to_the_batch_limit() {
+ Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'page' ) );
+
+ $post_ids = \range( 1, 25 );
+
+ Functions\expect( 'admin_url' )
+ ->once()
+ ->with( 'admin.php' )
+ ->andReturn( 'https://example.com/wp-admin/admin.php' );
+ Functions\expect( 'add_query_arg' )
+ ->once()
+ ->with(
+ [
+ 'page' => 'wpseo_page_bulk_edit',
+ 'content_type' => 'page',
+ 'post_ids' => \implode( ',', \range( 1, 20 ) ),
+ 'selected_count' => 25,
+ ],
+ 'https://example.com/wp-admin/admin.php',
+ )
+ ->andReturn( 'the-bulk-editor-url' );
+
+ $this->assertSame(
+ 'the-bulk-editor-url',
+ $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, $post_ids ),
+ );
+ }
+
+ /**
+ * Tests that without any usable IDs the redirect only carries the content type.
+ *
+ * @return void
+ */
+ public function test_redirects_without_ids_when_none_are_usable() {
+ Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'post' ) );
+
+ Functions\expect( 'admin_url' )
+ ->once()
+ ->with( 'admin.php' )
+ ->andReturn( 'https://example.com/wp-admin/admin.php' );
+ Functions\expect( 'add_query_arg' )
+ ->once()
+ ->with(
+ [
+ 'page' => 'wpseo_page_bulk_edit',
+ 'content_type' => 'post',
+ ],
+ 'https://example.com/wp-admin/admin.php',
+ )
+ ->andReturn( 'the-bulk-editor-url' );
+
+ $this->assertSame(
+ 'the-bulk-editor-url',
+ $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, [ 0, 'junk' ] ),
+ );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php
new file mode 100644
index 00000000000..e8de9fd61a1
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php
@@ -0,0 +1,81 @@
+once()
+ ->with( 'wpseo_manage_options' )
+ ->andReturn( false );
+
+ $this->content_types_repository->expects( 'get_content_types' )->never();
+
+ Filters\expectAdded( 'bulk_actions-edit-post' )->never();
+ Filters\expectAdded( 'handle_bulk_actions-edit-post' )->never();
+
+ $this->instance->register_bulk_actions();
+ }
+
+ /**
+ * Tests that the bulk action filters are registered for every supported post type.
+ *
+ * @return void
+ */
+ public function test_registers_the_bulk_action_filters_per_supported_post_type() {
+ Functions\expect( 'current_user_can' )
+ ->once()
+ ->with( 'wpseo_manage_options' )
+ ->andReturn( true );
+
+ $this->content_types_repository->expects( 'get_content_types' )
+ ->once()
+ ->andReturn( $this->content_types_for( [ 'post', 'page' ] ) );
+
+ Filters\expectAdded( 'bulk_actions-edit-post' )->once()->with( [ $this->instance, 'add_bulk_action' ] );
+ Filters\expectAdded( 'handle_bulk_actions-edit-post' )->once()->with( [ $this->instance, 'handle_bulk_action' ], 10, 3 );
+ Filters\expectAdded( 'bulk_actions-edit-page' )->once()->with( [ $this->instance, 'add_bulk_action' ] );
+ Filters\expectAdded( 'handle_bulk_actions-edit-page' )->once()->with( [ $this->instance, 'handle_bulk_action' ], 10, 3 );
+
+ $this->instance->register_bulk_actions();
+ }
+
+ /**
+ * Tests that without supported post types no filters are registered.
+ *
+ * @return void
+ */
+ public function test_registers_nothing_without_supported_post_types() {
+ Functions\expect( 'current_user_can' )
+ ->once()
+ ->with( 'wpseo_manage_options' )
+ ->andReturn( true );
+
+ $this->content_types_repository->expects( 'get_content_types' )
+ ->once()
+ ->andReturn( [] );
+
+ Filters\expectAdded( 'bulk_actions-edit-post' )->never();
+ Filters\expectAdded( 'handle_bulk_actions-edit-post' )->never();
+
+ $this->instance->register_bulk_actions();
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php
new file mode 100644
index 00000000000..0f4820903b4
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php
@@ -0,0 +1,28 @@
+once()->with( [ $this->instance, 'register_bulk_actions' ] );
+
+ $this->instance->register_hooks();
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php
index a3e0a3c3c5d..ac55936091e 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php
@@ -37,6 +37,7 @@ public function test_get_posts_without_author_restriction() {
$request->expects( 'get_param' )->with( 'search' )->andReturn( 'seo' );
$request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'draft', 'pending' ] );
$request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [ 'seo_title' ] );
+ $request->expects( 'get_param' )->with( 'include' )->andReturn( [] );
$this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( true );
$this->content_types_repository
@@ -92,6 +93,7 @@ public function test_get_posts_disables_scores_when_seo_analysis_inactive() {
$request->expects( 'get_param' )->with( 'search' )->andReturn( '' );
$request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] );
$request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [ 'seo_title' ] );
+ $request->expects( 'get_param' )->with( 'include' )->andReturn( [] );
$this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( false );
$this->content_types_repository
@@ -142,6 +144,7 @@ public function test_get_posts_restricts_to_own_posts_when_user_cannot_edit_othe
$request->expects( 'get_param' )->with( 'search' )->andReturn( '' );
$request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] );
$request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [] );
+ $request->expects( 'get_param' )->with( 'include' )->andReturn( [] );
$this->content_types_repository
->expects( 'get_content_types' )
@@ -193,6 +196,7 @@ public function test_get_posts_falls_back_to_all_statuses_when_none_selected() {
$request->expects( 'get_param' )->with( 'search' )->andReturn( '' );
$request->expects( 'get_param' )->with( 'status' )->andReturn( [] );
$request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [] );
+ $request->expects( 'get_param' )->with( 'include' )->andReturn( [] );
$this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( true );
$this->content_types_repository
@@ -230,6 +234,57 @@ static function ( $query ) {
$this->assertInstanceOf( WP_REST_Response::class, $this->instance->get_posts( $request ) );
}
+ /**
+ * Tests that the included post IDs are deduplicated and carried into the query.
+ *
+ * @return void
+ */
+ public function test_get_posts_carries_the_included_post_ids() {
+ $request = Mockery::mock( WP_REST_Request::class );
+ $request->expects( 'get_param' )->with( 'content_type' )->andReturn( 'page' );
+ $request->expects( 'get_param' )->with( 'page' )->andReturn( 1 );
+ $request->expects( 'get_param' )->with( 'per_page' )->andReturn( 20 );
+ $request->expects( 'get_param' )->with( 'search' )->andReturn( '' );
+ $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] );
+ $request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [] );
+ $request->expects( 'get_param' )->with( 'include' )->andReturn( [ 5, '3', 3, 5 ] );
+ $this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( true );
+
+ $this->content_types_repository
+ ->expects( 'get_content_types' )
+ ->once()
+ ->andReturn(
+ [
+ [
+ 'name' => 'page',
+ 'label' => 'Pages',
+ ],
+ ],
+ );
+
+ $this->content_type_access_checker->expects( 'can_edit_others' )->with( 'page' )->andReturnTrue();
+
+ $posts_page = Mockery::mock( Posts_Page::class );
+ $posts_page->expects( 'to_array' )->once()->andReturn( [] );
+
+ $this->posts_repository
+ ->expects( 'get_posts' )
+ ->once()
+ ->with(
+ Mockery::on(
+ static function ( $query ) {
+ return $query instanceof Posts_Query
+ && $query->get_include_ids() === [ 5, 3 ];
+ },
+ ),
+ )
+ ->andReturn( $posts_page );
+
+ Mockery::mock( 'overload:' . WP_REST_Response::class );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $this->instance->get_posts( $request ) );
+ }
+
/**
* Tests that an unknown content type returns a WP_Error.
*
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php
index 84fa4522e83..77e2033e98e 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php
@@ -83,6 +83,17 @@ public function test_register_routes() {
],
'description' => 'The fields to filter posts by; a field matches when it is empty, or (for search fields with SEO analysis enabled) when its score needs improvement.',
],
+ 'include' => [
+ 'required' => false,
+ 'type' => 'array',
+ 'default' => [],
+ 'maxItems' => 100,
+ 'items' => [
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ],
+ 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.',
+ ],
],
'callback' => [ $this->instance, 'get_posts' ],
'permission_callback' => [ $this->instance, 'check_permissions' ],
diff --git a/tests/Unit/Helpers/Current_Page_Helper_Test.php b/tests/Unit/Helpers/Current_Page_Helper_Test.php
index 0b4315c119f..578fcdb99f5 100644
--- a/tests/Unit/Helpers/Current_Page_Helper_Test.php
+++ b/tests/Unit/Helpers/Current_Page_Helper_Test.php
@@ -894,4 +894,45 @@ public function test_get_current_yoast_seo_page_page_is_int() {
$this->assertEquals( null, $this->instance->get_current_yoast_seo_page() );
}
+
+ /**
+ * Test is_trash_overview function when the overview shows the trash.
+ *
+ * @covers ::is_trash_overview
+ *
+ * @return void
+ */
+ public function test_is_trash_overview() {
+ $_REQUEST['post_status'] = 'trash';
+
+ $this->assertTrue( $this->instance->is_trash_overview() );
+
+ unset( $_REQUEST['post_status'] );
+ }
+
+ /**
+ * Test is_trash_overview function when the overview shows another status.
+ *
+ * @covers ::is_trash_overview
+ *
+ * @return void
+ */
+ public function test_is_trash_overview_other_status() {
+ $_REQUEST['post_status'] = 'draft';
+
+ $this->assertFalse( $this->instance->is_trash_overview() );
+
+ unset( $_REQUEST['post_status'] );
+ }
+
+ /**
+ * Test is_trash_overview function when no status is requested.
+ *
+ * @covers ::is_trash_overview
+ *
+ * @return void
+ */
+ public function test_is_trash_overview_without_status() {
+ $this->assertFalse( $this->instance->is_trash_overview() );
+ }
}