Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
73c0c45
Enhances Bulk Editor Integration with User Helper
JorPV Jul 23, 2026
9d78d8e
Implement opt-in notification feature in bulk editor
JorPV Jul 24, 2026
2c395b6
Refactor General Page Integration and Opt-In Route by removing the 't…
JorPV Jul 24, 2026
7b85279
Refines the guided tour feature for the bulk editor
JorPV Jul 24, 2026
e1e0131
Add unit tests for BulkEditorTourNotification component
JorPV Jul 24, 2026
8f90a6e
Merge remote-tracking branch 'origin/trunk' into 1348-bulk-editor-in-…
JorPV Jul 24, 2026
d06ff51
Updated the formatting
JorPV Jul 24, 2026
12a0492
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 1348-bulk…
JorPV Jul 27, 2026
188cd8e
Enhance bulk editor guided tour functionality and styling
JorPV Jul 27, 2026
55884c6
Refactor tour tests to enhance spotlight functionality and layout
JorPV Jul 27, 2026
74c7472
chore(bulk-editor): drop unrelated changes reintroduced on the branch
JorPV Jul 27, 2026
b189591
fix(bulk-editor): align CS threshold and fix tour comment nits
JorPV Jul 27, 2026
dca3457
fix(bulk-editor): tighten single-region tour spotlight to uniform 6px…
JorPV Jul 27, 2026
c8312b5
Enhance guided tour functionality with RTL support and child selection
JorPV Jul 28, 2026
21f6489
Update return type in get_script_data() documentation
JorPV Jul 28, 2026
43bac2e
Enhance bulk editor tour functionality by adding target missing detec…
JorPV Jul 28, 2026
fb75c6c
Registers hideOptInNotification to the store and adjusts tests
JorPV Jul 28, 2026
0a28560
Apply review comments
JorPV Jul 29, 2026
a632264
Enhance bulk editor tour styling
JorPV Jul 29, 2026
0cc081c
Remove LlmTxtPopover component and related references; clean up CSS.
JorPV Jul 29, 2026
0ac425c
Merge remote-tracking branch 'origin/trunk' into 1348-bulk-editor-in-…
JorPV Jul 29, 2026
0cb42fb
fix: change button size for bulk editor tour
vraja-pro Jul 30, 2026
b17691c
Add highlightChildren property to tour step for improved spotlight
JorPV Jul 30, 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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
"Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards"
],
"check-cs-thresholds": [
"@putenv YOASTCS_THRESHOLD_ERRORS=2378",
"@putenv YOASTCS_THRESHOLD_ERRORS=2377",
"@putenv YOASTCS_THRESHOLD_WARNINGS=254",
"Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds"
],
Expand Down
33 changes: 33 additions & 0 deletions css/src/bulk-editor-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
padding-left: 0 !important;
}

/* Keep the admin menu above the guided-tour spotlight dim so it isn't overlaid. */
#adminmenuback {
z-index: 11;
}

.yst-mobile-navigation__top {
/* Offset the sticky bar below the taller mobile admin bar. */
@media (min-width: 601px) and (max-width: 768px) {
Expand Down Expand Up @@ -107,3 +112,31 @@
.yst-root .yst-bulk-editor-title-link:focus:not(:focus-visible) {
@apply yst-outline-none;
}

/* The guided-tour spotlight overlay is decorative only; a separate backdrop blocks interaction, so it ignores pointer events. */
.yst-root .yst-tour-spotlight {
pointer-events: none;
}

/* Prevent the in-app tour popovers title to stack bellow the close button */
.yst-bulk-editor-tour-card .yst-flex-grow {
min-width: 0;
padding-inline-end: 2rem;
}

/* In RTL. */
.yst-bulk-editor-tour-card.yst-popover--right {
margin-inline-start: 0.25rem;
}

[dir="rtl"] .yst-bulk-editor-tour-card.yst-popover--right {
margin-inline-start: 1rem;
}

[dir="rtl"] .yst-bulk-editor-tour-card.yst-popover--right::before {
inset-inline-start: auto !important;
inset-inline-end: 100% !important;
transform: translateY(-50%) !important;
border-inline-start: 14px solid transparent !important;
border-inline-end: 14px solid white !important;
}
4 changes: 0 additions & 4 deletions css/src/new-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,6 @@ body.seo_page_wpseo_page_settings {
}
}

.yst-popover-backdrop-highlight-button button {
z-index:99;
}

/* RTL */

&.rtl {
Expand Down
42 changes: 23 additions & 19 deletions packages/js/src/bulk-editor/components/bulk-action-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,23 @@ export const SelectionToolbar = ( { idSuffix = "", isAllSelected, isIndeterminat

return (
<div className="yst-flex yst-items-center yst-gap-4">
<Checkbox
ref={ checkboxRef }
id={ `bulk-editor-select-all${ idSuffix }` }
name={ `bulk-editor-select-all${ idSuffix }` }
value="all"
aria-label={ __( "Select all", "wordpress-seo" ) }
checked={ isAllSelected }
onChange={ onToggleAll }
/>
<SelectMenu
onSelectAll={ onSelectAll }
onDeselectAll={ onDeselectAll }
selectedCount={ selectedCount }
totalCount={ totalCount }
/>
<div className="yst-flex yst-items-center yst-gap-4" data-tour-id="selection-toolbar">
<Checkbox
ref={ checkboxRef }
id={ `bulk-editor-select-all${ idSuffix }` }
name={ `bulk-editor-select-all${ idSuffix }` }
value="all"
aria-label={ __( "Select all", "wordpress-seo" ) }
checked={ isAllSelected }
onChange={ onToggleAll }
/>
<SelectMenu
onSelectAll={ onSelectAll }
onDeselectAll={ onDeselectAll }
selectedCount={ selectedCount }
totalCount={ totalCount }
/>
</div>
{ selectedCount > 0 && (
<span className="yst-font-medium yst-text-slate-800">
{ sprintf(
Expand Down Expand Up @@ -250,10 +252,12 @@ const BulkActionsBand = ( {
isPremium, isAiEnabled, isActive, selectedIds, activeFieldSet, contentType, hasUnsavedEdits, editCount, onApplyAll, onDiscardAll, isApplyingAll,
} ) => (
<div className="yst-flex yst-items-center yst-gap-3 yst-border-y yst-border-slate-200 yst-bg-slate-100 yst-px-4 yst-py-3">
{ ! isPremium && isAiEnabled && <FreeBulkActions contentType={ contentType } /> }
{ isActive && (
<Slot name={ BULK_ACTIONS_SLOT } fillProps={ { selectedIds, activeFieldSet, contentType, hasUnsavedEdits } } />
) }
<div className="yst-flex yst-items-center yst-gap-3" data-tour-id="generate-actions">
{ ! isPremium && isAiEnabled && <FreeBulkActions contentType={ contentType } /> }
{ isActive && (
<Slot name={ BULK_ACTIONS_SLOT } fillProps={ { selectedIds, activeFieldSet, contentType, hasUnsavedEdits } } />
) }
</div>
{ isActive && hasUnsavedEdits && (
<ManualReviewActions editCount={ editCount } onApplyAll={ onApplyAll } onDiscardAll={ onDiscardAll } isApplying={ isApplyingAll } />
) }
Expand Down
164 changes: 84 additions & 80 deletions packages/js/src/bulk-editor/components/bulk-editor-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useInlineEdit } from "../hooks/use-inline-edit";
import { usePosts } from "../services/use-posts";
import { BulkActions, SelectionToolbar } from "./bulk-action-bar";
import { BulkEditorFilters } from "./bulk-editor-filters";
import { BulkEditorTour } from "./tour/bulk-editor-tour";
import { BulkEditorFooter } from "./bulk-editor-footer";
import { BulkEditorTable } from "./table/bulk-editor-table";
import { BulkEditorTabPanel, BulkEditorTabs } from "./bulk-editor-tabs";
Expand Down Expand Up @@ -144,87 +145,90 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
} ), [ selectedIds, toggleRow ] );

return (
<div className="yst-p-8 yst-space-y-6">
<div className="yst-flex yst-flex-col yst-gap-4 sm:yst-flex-row sm:yst-items-start sm:yst-justify-between">
<BulkEditorTabs
tabs={ tabs }
activeTab={ activeFieldSet }
disabled={ hasExternalGeneration }
onChange={ onChangeTab }
label={ __( "Bulk editor views", "wordpress-seo" ) }
<>
<div className="yst-p-8 yst-space-y-6">
<div className="yst-flex yst-flex-col yst-gap-4 sm:yst-flex-row sm:yst-items-start sm:yst-justify-between">
<BulkEditorTabs
tabs={ tabs }
activeTab={ activeFieldSet }
disabled={ hasExternalGeneration }
onChange={ onChangeTab }
label={ __( "Bulk editor views", "wordpress-seo" ) }
/>
<SearchBox contentTypeLabel={ contentTypeLabel } />
</div>
{ tabs.map( ( tab ) => (
<BulkEditorTabPanel key={ tab.id } tabId={ tab.id } isActive={ tab.id === activeFieldSet }>
<BulkEditorTable
items={ items }
fieldSet={ fieldSets[ tab.id ] }
selection={ selection }
editing={ editing }
selectionToolbar={
<SelectionToolbar
idSuffix={ `-${ tab.id }` }
isAllSelected={ isAllSelected }
isIndeterminate={ isIndeterminate }
onToggleAll={ onToggleAll }
onSelectAll={ onSelectAll }
onDeselectAll={ deselectAll }
selectedCount={ selectedCount }
totalCount={ totalCount }
contentTypeLabel={ contentTypeLabel }
/>
}
bulkActions={
<BulkActions
isPremium={ isPremium }
isAiEnabled={ isAiEnabled }
isActive={ tab.id === activeFieldSet }
selectedIds={ selectedIds }
activeFieldSet={ activeFieldSet }
contentType={ contentType }
contentTypeLabel={ contentTypeLabel }
contentTypeSingularLabel={ contentTypeSingularLabel }
hasUnsavedEdits={ hasUnsavedEdits }
editCount={ editCount }
onApplyAll={ editing.onApplyAll }
onDiscardAll={ editing.onDiscardAll }
isApplyingAll={ editing.isApplyingAll }
hasSaveError={ editing.hasSaveError }
onDismissSaveError={ editing.dismissSaveError }
/>
}
// 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 }
filters={ <BulkEditorFilters /> }
isLoading={ isPending }
hasExternalPendingChanges={ hasExternalPendingChanges }
hasExternalGeneration={ hasExternalGeneration }
footer={ total > 0
? <BulkEditorFooter total={ total } totalPages={ totalPages } isPending={ isPending } />
: null }
/>
</BulkEditorTabPanel>
) ) }
<UnsavedChangesModal
isOpen={ hasUnsavedEdits && pendingSwitch !== null }
isSaving={ editing.isApplyingAll }
onSave={ onSaveAndSwitch }
onDiscard={ onDiscardAndSwitch }
onClose={ onCancelSwitch }
/>
<Slot
name={ PENDING_CHANGES_MODAL_SLOT }
fillProps={ {
isOpen: pendingSwitch !== null && ! hasUnsavedEdits,
onCommit: onCommitSwitch,
onCancel: onCancelSwitch,
} }
/>
<SearchBox contentTypeLabel={ contentTypeLabel } />
</div>
{ tabs.map( ( tab ) => (
<BulkEditorTabPanel key={ tab.id } tabId={ tab.id } isActive={ tab.id === activeFieldSet }>
<BulkEditorTable
items={ items }
fieldSet={ fieldSets[ tab.id ] }
selection={ selection }
editing={ editing }
selectionToolbar={
<SelectionToolbar
idSuffix={ `-${ tab.id }` }
isAllSelected={ isAllSelected }
isIndeterminate={ isIndeterminate }
onToggleAll={ onToggleAll }
onSelectAll={ onSelectAll }
onDeselectAll={ deselectAll }
selectedCount={ selectedCount }
totalCount={ totalCount }
contentTypeLabel={ contentTypeLabel }
/>
}
bulkActions={
<BulkActions
isPremium={ isPremium }
isAiEnabled={ isAiEnabled }
isActive={ tab.id === activeFieldSet }
selectedIds={ selectedIds }
activeFieldSet={ activeFieldSet }
contentType={ contentType }
contentTypeLabel={ contentTypeLabel }
contentTypeSingularLabel={ contentTypeSingularLabel }
hasUnsavedEdits={ hasUnsavedEdits }
editCount={ editCount }
onApplyAll={ editing.onApplyAll }
onDiscardAll={ editing.onDiscardAll }
isApplyingAll={ editing.isApplyingAll }
hasSaveError={ editing.hasSaveError }
onDismissSaveError={ editing.dismissSaveError }
/>
}
// 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 }
filters={ <BulkEditorFilters /> }
isLoading={ isPending }
hasExternalPendingChanges={ hasExternalPendingChanges }
hasExternalGeneration={ hasExternalGeneration }
footer={ total > 0
? <BulkEditorFooter total={ total } totalPages={ totalPages } isPending={ isPending } />
: null }
/>
</BulkEditorTabPanel>
) ) }
<UnsavedChangesModal
isOpen={ hasUnsavedEdits && pendingSwitch !== null }
isSaving={ editing.isApplyingAll }
onSave={ onSaveAndSwitch }
onDiscard={ onDiscardAndSwitch }
onClose={ onCancelSwitch }
/>
<Slot
name={ PENDING_CHANGES_MODAL_SLOT }
fillProps={ {
isOpen: pendingSwitch !== null && ! hasUnsavedEdits,
onCommit: onCommitSwitch,
onCancel: onCancelSwitch,
} }
/>
</div>
<BulkEditorTour onSelectAll={ onSelectAll } onDeselectAll={ deselectAll } hasSelection={ hasSelection } />
</>
);
};
57 changes: 38 additions & 19 deletions packages/js/src/bulk-editor/components/bulk-editor-nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { noop } from "lodash";
import { YoastLogo } from "../../shared-admin/components";
import { BackToToolsLink } from "./back-to-tools-link";

// The WordPress built-in content types, used to scope the guided-tour spotlight to Posts and Pages.
const BUILT_IN_CONTENT_TYPES = [ "post", "page" ];

/**
* One content type entry.
*
Expand All @@ -19,14 +22,15 @@ import { BackToToolsLink } from "./back-to-tools-link";
* One content type item. Selecting a content type changes view state, it does not navigate.
* The active state comes from the SidebarNavigation context.
*
* @param {Object} props The props.
* @param {BulkEditorContentType} props.contentType The content type.
* @param {boolean} props.disabled Whether the item is disabled.
* @param {Function} props.onChange Called with the content type id when selected.
* @param {Object} props The props.
* @param {BulkEditorContentType} props.contentType The content type.
* @param {boolean} props.disabled Whether the item is disabled.
* @param {Function} props.onChange Called with the content type id when selected.
* @param {boolean} [props.isHighlightEnd] Whether this is the last item the guided-tour spotlight covers.
*
* @returns {JSX.Element} The item.
*/
const ContentTypeItem = ( { contentType, disabled, onChange } ) => {
const ContentTypeItem = ( { contentType, disabled, onChange, isHighlightEnd = false } ) => {
const handleClick = useCallback( () => onChange( contentType.id ), [ onChange, contentType.id ] );

return (
Expand All @@ -39,6 +43,7 @@ const ContentTypeItem = ( { contentType, disabled, onChange } ) => {
disabled={ disabled }
onClick={ handleClick }
className={ classNames( "yst-w-full", { "yst-opacity-50": disabled } ) }
{ ...( isHighlightEnd ? { "data-tour-highlight-end": "true" } : {} ) }
/>
);
};
Expand Down Expand Up @@ -111,6 +116,12 @@ export const BulkEditorNavMenu = ( {
} ) => {
const hiddenCount = contentTypes.length - visibleLimit;

// The in-app guided tour highlights only the built-in content types (Posts, Pages).
// Post/page are always on top of the registration order.
const highlightEndIndex = contentTypes
.slice( 0, 2 )
.reduce( ( end, contentType, index ) => ( BUILT_IN_CONTENT_TYPES.includes( contentType.id ) ? index : end ), -1 );

const showMoreLabel = sprintf(
/* translators: %d expands to the number of additional content types. */
_n( "Show %d more", "Show %d more", hiddenCount, "wordpress-seo" ),
Expand All @@ -121,20 +132,28 @@ export const BulkEditorNavMenu = ( {
<div className="yst-space-y-6">
<NavLogo logoHref={ logoHref } isPremium={ isPremium } idSuffix={ idSuffix } disabled={ disabled } onNavigate={ onNavigate } />
<BackToToolsLink href={ backToToolsUrl } disabled={ disabled } onNavigate={ onNavigate } />
<SidebarNavigation.MenuItemWithLimiter
id={ `bulk-editor-nav-content-types${ idSuffix }` }
label={ __( "Bulk editor", "wordpress-seo" ) }
icon={ DuplicateIcon }
defaultOpen={ true }
limit={ visibleLimit }
buttonId={ `bulk-editor-nav-more${ idSuffix }` }
showMoreLabel={ showMoreLabel }
showLessLabel={ __( "Show less", "wordpress-seo" ) }
>
{ contentTypes.map( ( contentType ) => (
<ContentTypeItem key={ contentType.id } contentType={ contentType } disabled={ disabled } onChange={ onChange } />
) ) }
</SidebarNavigation.MenuItemWithLimiter>
<div data-tour-id="content-type-nav">
<SidebarNavigation.MenuItemWithLimiter
id={ `bulk-editor-nav-content-types${ idSuffix }` }
label={ __( "Bulk editor", "wordpress-seo" ) }
icon={ DuplicateIcon }
defaultOpen={ true }
limit={ visibleLimit }
buttonId={ `bulk-editor-nav-more${ idSuffix }` }
showMoreLabel={ showMoreLabel }
showLessLabel={ __( "Show less", "wordpress-seo" ) }
>
{ contentTypes.map( ( contentType, index ) => (
<ContentTypeItem
key={ contentType.id }
contentType={ contentType }
disabled={ disabled }
onChange={ onChange }
isHighlightEnd={ index === highlightEndIndex }
/>
) ) }
</SidebarNavigation.MenuItemWithLimiter>
</div>
</div>
);
};
Loading
Loading