Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2af957b
Register the bulk action for each of the supported content types
pls78 Jul 22, 2026
545f4f8
Utility to check if the user is on the trash page
pls78 Jul 22, 2026
87efb17
Fetch the selected posts coming from the overview
pls78 Jul 22, 2026
1155219
Add the optional include list to support the new custom filter
pls78 Jul 22, 2026
7c3871b
Use the new custom filter
pls78 Jul 22, 2026
9b58b77
Pass the list of the elements shown by the new custom filter
pls78 Jul 22, 2026
21f9e5e
Take into account the overview filter
pls78 Jul 22, 2026
ace38d7
Add the overview filter logic
pls78 Jul 22, 2026
616b12f
Get alredy-selected elements (if any) coming from the overview page
pls78 Jul 22, 2026
f59dd9c
Add the overview filter
pls78 Jul 22, 2026
faa48d6
Notice to be shown when more than 20 elements were selected in the ov…
pls78 Jul 22, 2026
c376795
Refactor to lower the complexity
pls78 Jul 22, 2026
0f7ba45
Use the preselected elements
pls78 Jul 22, 2026
b028867
Add the notice
pls78 Jul 22, 2026
795c513
Left out by mistake from the previous commit
pls78 Jul 22, 2026
c8217d7
Add tests
pls78 Jul 22, 2026
29b8a2b
Fix cs
pls78 Jul 23, 2026
5295eda
Fix cs
pls78 Jul 23, 2026
c59639b
Fix cs
pls78 Jul 23, 2026
fba9369
Prune "invisible" posts from the selected list
pls78 Jul 23, 2026
d844333
Add a notice informing the user when some posts selected in the overv…
pls78 Jul 23, 2026
7138dc3
Merge branch 'feature/bulk-editor-ph2' into 1311-add-edit-with-yoast-…
pls78 Jul 27, 2026
c9ed57c
Don't translate the product name
pls78 Jul 27, 2026
c0f4cb7
Proper check and sanitization
pls78 Jul 27, 2026
a26559c
Add test
pls78 Jul 27, 2026
09b797b
Add bottom divider to the Overview selection filter like the one sepa…
pls78 Jul 27, 2026
d7ee676
Add comment
pls78 Jul 29, 2026
7cace70
Add test
pls78 Jul 29, 2026
6f7ae81
Merge remote-tracking branch 'origin/feature/bulk-editor-ph2' into 13…
pls78 Jul 29, 2026
6e6d309
Merge remote-tracking branch 'origin/feature/bulk-editor-ph2' into 13…
pls78 Jul 31, 2026
c84ff40
Extract reusable alert
pls78 Jul 31, 2026
894affe
Use new alert component
pls78 Jul 31, 2026
bc5eb53
Use generic "item" instead of content type in copy
pls78 Jul 31, 2026
6cd71e6
Update tests
pls78 Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions css/src/bulk-editor-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
46 changes: 28 additions & 18 deletions packages/js/src/bulk-editor/components/bulk-action-bar.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 <Alert variant="error" as="div" role="alert" className="yst-rounded-none yst-relative">
export const ManualSaveErrorNotice = ( { onDismiss } ) => (
<DismissibleAlert variant="error" role="alert" onDismiss={ onDismiss }>
<div className="yst-flex yst-flex-col yst-gap-1">
<span className="yst-block yst-font-medium">{ __( "Couldn't save your edits.", "wordpress-seo" ) }</span>
<span className="yst-font-normal">{ __( "Something went wrong. Please try again.", "wordpress-seo" ) }</span>
</div>
<button
type="button"
className="yst-absolute yst-end-4 yst-top-4 yst-text-current hover:yst-opacity-75 yst-cursor-pointer"
onClick={ onDismiss }
aria-label={ __( "Dismiss", "wordpress-seo" ) }
>
<SolidXIcon className="yst-h-5 yst-w-5" { ...svgAriaProps } />
</button>
</Alert>;
};
</DismissibleAlert>
);

/**
* 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.
Expand All @@ -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,
} ) => (
<>
<OverviewSelectionNotice total={ preselectedTotal } onDismiss={ onDismissPreselection } />
<OverviewExclusionNotice hasExclusions={ hasExcludedPreselected } onDismiss={ onDismissExclusion } />
{ hasSaveError && <ManualSaveErrorNotice onDismiss={ onDismissSaveError } /> }
<Slot
name={ BULK_NOTICES_SLOT }
Expand Down Expand Up @@ -245,16 +246,25 @@ const BulkActionsBand = ( {
* @param {boolean} [props.isApplyingAll] Whether an apply-all is in flight; disables the review actions.
* @param {boolean} [props.hasSaveError] Whether the last apply-all failed; shows the inline save-error notice.
* @param {Function} [props.onDismissSaveError] Dismisses the save-error notice.
* @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.
*
* @returns {JSX.Element} The bulk actions row content.
*/
export const BulkActions = ( {
isPremium, isAiEnabled = false, isActive, selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel,
hasUnsavedEdits, editCount, onApplyAll, onDiscardAll, isApplyingAll, hasSaveError, onDismissSaveError,
preselectedTotal, onDismissPreselection, hasExcludedPreselected, onDismissExclusion,
} ) => (
<div className="yst-flex yst-flex-col">
{ isActive && (
<BulkActionsNotices
preselectedTotal={ preselectedTotal }
onDismissPreselection={ onDismissPreselection }
hasExcludedPreselected={ hasExcludedPreselected }
onDismissExclusion={ onDismissExclusion }
hasSaveError={ hasSaveError }
onDismissSaveError={ onDismissSaveError }
selectedIds={ selectedIds }
Expand Down
60 changes: 53 additions & 7 deletions packages/js/src/bulk-editor/components/bulk-editor-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useDispatch, useSelect } from "@wordpress/data";
import { useCallback, useEffect, useMemo } from "@wordpress/element";
import { __ } from "@wordpress/i18n";
import {
BULK_UPDATE_BATCH_SIZE,
PENDING_CHANGES_MODAL_SLOT,
STORE_NAME,
} from "../constants";
Expand All @@ -18,6 +19,40 @@ import { UnsavedChangesModal } from "./unsaved-changes-modal";
import { SearchBox } from "./search-box";
import { getSelectionView, getSmartSelectItems } from "../helpers";

/**
* Decides whether the bulk-actions band row is expanded.
*
* 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. The overview-selection truncation and exclusion notices live in the band's notices region,
* so either opens the band too.
*
* @param {Object} view The view state.
* @param {boolean} view.hasSelection Whether any rows are selected.
* @param {boolean} view.isAiEnabled Whether the AI feature is enabled.
* @param {boolean} view.hasUnsavedEdits Whether a row has unsaved manual edits.
* @param {boolean} view.hasExternalPendingChanges Whether an external plugin reports pending changes.
* @param {boolean} view.hasOverviewNotice Whether an overview-selection notice (truncation or exclusion) must show.
*
* @returns {boolean} Whether the band is expanded.
*/
export const shouldShowBulkActions = ( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ) =>
( 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.
*
Expand All @@ -39,6 +74,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
const {
activeFieldSet,
selectedIds,
preselectedTotal,
hasExcludedPreselected,
isPremium,
isAiEnabled,
hasExternalPendingChanges,
Expand All @@ -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.
Expand All @@ -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 } );
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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={ <BulkEditorFilters /> }
isLoading={ isPending }
hasExternalPendingChanges={ hasExternalPendingChanges }
Expand Down
29 changes: 25 additions & 4 deletions packages/js/src/bulk-editor/components/bulk-editor-filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 );
Expand All @@ -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.
Expand Down Expand Up @@ -79,7 +91,7 @@ export const BulkEditorFilters = () => {
};
}, [ isOpen ] );

const appliedCount = statuses.length + needsImprovement.length;
const appliedCount = statuses.length + needsImprovement.length + ( isOverviewFilterActive ? 1 : 0 );

return (
<div ref={ containerRef } className="yst-relative">
Expand All @@ -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 && (
<CheckboxGroup
id="bulk-editor-overview-filter"
className="yst-bulk-editor-filter-group yst-bulk-editor-overview-selection"
options={ overviewOptions }
values={ isOverviewFilterActive ? [ "overview" ] : [] }
onChange={ onChangeOverviewFilter }
/>
) }
<CheckboxGroup
id="bulk-editor-status-filter"
className="yst-bulk-editor-filter-group"
Expand Down
34 changes: 34 additions & 0 deletions packages/js/src/bulk-editor/components/dismissible-alert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import SolidXIcon from "@heroicons/react/solid/XIcon";
import { __ } from "@wordpress/i18n";
import { Alert, useSvgAria } from "@yoast/ui-library";
import classNames from "classnames";

/**
* An alert with a dismiss (X) button in its top-end corner, shared by the notices in the bulk-actions band.
*
* @param {Object} props The props.
* @param {string} [props.variant] The alert variant.
* @param {string} [props.role] The alert role.
* @param {string} [props.className] Extra class names for the alert.
* @param {Function} props.onDismiss Dismisses the notice.
* @param {JSX.node} props.children The notice content.
*
* @returns {JSX.Element} The dismissible alert.
*/
export const DismissibleAlert = ( { variant = "info", role = "status", className = "", onDismiss, children } ) => {
const svgAriaProps = useSvgAria();

return (
<Alert variant={ variant } as="div" role={ role } className={ classNames( "yst-rounded-none yst-relative", className ) }>
{ children }
<button
type="button"
className="yst-absolute yst-end-4 yst-top-4 yst-text-current hover:yst-opacity-75 yst-cursor-pointer"
onClick={ onDismiss }
aria-label={ __( "Dismiss", "wordpress-seo" ) }
>
<SolidXIcon className="yst-h-5 yst-w-5" { ...svgAriaProps } />
</button>
</Alert>
);
};
Original file line number Diff line number Diff line change
@@ -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).
<DismissibleAlert className="yst-mt-2 first:yst-mt-0" onDismiss={ onDismiss }>
<span className="yst-block yst-pe-8">
{ __( "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded.", "wordpress-seo" ) }
</span>
</DismissibleAlert>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<DismissibleAlert onDismiss={ onDismiss }>
<span className="yst-block yst-pe-8">{ message }</span>
</DismissibleAlert>
);
};
Loading
Loading