) }
diff --git a/packages/js/src/bulk-editor/components/bulk-editor-content.js b/packages/js/src/bulk-editor/components/bulk-editor-content.js
index 043a684ff3b..4d325df4bb7 100644
--- a/packages/js/src/bulk-editor/components/bulk-editor-content.js
+++ b/packages/js/src/bulk-editor/components/bulk-editor-content.js
@@ -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";
@@ -144,87 +145,90 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
} ), [ selectedIds, toggleRow ] );
return (
-
-
-
+
+
+
+
+
+ { tabs.map( ( tab ) => (
+
+
+ }
+ bulkActions={
+
+ }
+ // 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={ }
+ isLoading={ isPending }
+ hasExternalPendingChanges={ hasExternalPendingChanges }
+ hasExternalGeneration={ hasExternalGeneration }
+ footer={ total > 0
+ ?
+ : null }
+ />
+
+ ) ) }
+
+
-
- { tabs.map( ( tab ) => (
-
-
- }
- bulkActions={
-
- }
- // 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={ }
- isLoading={ isPending }
- hasExternalPendingChanges={ hasExternalPendingChanges }
- hasExternalGeneration={ hasExternalGeneration }
- footer={ total > 0
- ?
- : null }
- />
-
- ) ) }
-
-
-
+
+ >
);
};
diff --git a/packages/js/src/bulk-editor/components/bulk-editor-nav.js b/packages/js/src/bulk-editor/components/bulk-editor-nav.js
index 269ea59d854..cc26edb6c8f 100644
--- a/packages/js/src/bulk-editor/components/bulk-editor-nav.js
+++ b/packages/js/src/bulk-editor/components/bulk-editor-nav.js
@@ -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.
*
@@ -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 (
@@ -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" } : {} ) }
/>
);
};
@@ -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" ),
@@ -121,20 +132,28 @@ export const BulkEditorNavMenu = ( {
-
- { contentTypes.map( ( contentType ) => (
-
- ) ) }
-
+
+
+ { contentTypes.map( ( contentType, index ) => (
+
+ ) ) }
+
+
);
};
diff --git a/packages/js/src/bulk-editor/components/bulk-editor-tabs.js b/packages/js/src/bulk-editor/components/bulk-editor-tabs.js
index 1fcb98ca74a..a276a5da197 100644
--- a/packages/js/src/bulk-editor/components/bulk-editor-tabs.js
+++ b/packages/js/src/bulk-editor/components/bulk-editor-tabs.js
@@ -122,7 +122,7 @@ export const BulkEditorTabs = ( { tabs, activeTab, disabled = false, onChange, l
}, [ tabs, activeTab, onChange, disabled ] );
return (
-
+
{ tabs.map( ( tab ) => (
{
+ const { isSeen, isAiEnabled } = useSelect( ( select ) => {
+ const store = select( STORE_NAME );
+ return {
+ isSeen: store.selectIsOptInNotificationSeen( TOUR_OPT_IN_KEY ),
+ isAiEnabled: store.selectPreference( "isAiEnabled", false ),
+ };
+ }, [] );
+ const { setOptInNotificationSeen, hideOptInNotification } = useDispatch( STORE_NAME );
+
+ // The generate step's target only exists when AI is enabled, so drop it otherwise.
+ const steps = useMemo(
+ () => getTourSteps().filter( ( tourStep ) => ! tourStep.requiresSelection || isAiEnabled ),
+ [ isAiEnabled ]
+ );
+
+ const [ stepIndex, setStepIndex ] = useState( 0 );
+ const [ isDismissed, setIsDismissed ] = useState( false );
+ // Whether the tour created the selection itself, so it can undo it without clearing a selection the user made.
+ const didAutoSelect = useRef( false );
+
+ const isActive = ! isSeen && ! isDismissed;
+ const step = steps[ stepIndex ];
+ const isLastStep = stepIndex === steps.length - 1;
+
+ useEffect( () => {
+ if ( isActive && step.requiresSelection && ! hasSelection ) {
+ didAutoSelect.current = true;
+ onSelectAll();
+ }
+ }, [ isActive, step, hasSelection, onSelectAll ] );
+
+ const { spotlight, targetMissing } = useTourAnchor( `[data-tour-id="${ step.tourId }"]`, isActive, {
+ endSelector: step.highlightEndSelector,
+ perChild: step.highlightChildren,
+ childSelector: step.highlightChildrenSelector,
+ } );
+
+ const finish = useCallback( () => {
+ if ( didAutoSelect.current ) {
+ onDeselectAll();
+ didAutoSelect.current = false;
+ }
+ setIsDismissed( true );
+ // Persist to the server and update local state, so the tour stays gone this session and on reload.
+ setOptInNotificationSeen( TOUR_OPT_IN_KEY );
+ hideOptInNotification( TOUR_OPT_IN_KEY );
+ }, [ onDeselectAll, setOptInNotificationSeen, hideOptInNotification ] );
+
+ const onNext = useCallback( () => {
+ // finish() dispatches store updates, so it runs from the event handler, not inside a state updater.
+ if ( isLastStep ) {
+ finish();
+ return;
+ }
+ setStepIndex( ( index ) => index + 1 );
+ }, [ isLastStep, finish ] );
+
+ const onBack = useCallback( () => setStepIndex( ( index ) => Math.max( 0, index - 1 ) ), [] );
+
+ // If the active step's target never appears (e.g. the generate step when there are no selectable rows),
+ // advance past it instead of leaving the tour invisible-but-active.
+ useEffect( () => {
+ if ( isActive && targetMissing ) {
+ onNext();
+ }
+ }, [ isActive, targetMissing, onNext ] );
+
+ if ( ! isActive || ! spotlight ) {
+ return null;
+ }
+
+ const { rects, bounds, viewport } = spotlight;
+
+ return (
+ <>
+
+
+
+
+
+ { rects.map( ( rect, index ) => (
+
+ ) ) }
+
+
+
+
+
+ 0 ? onBack : null }
+ onSkip={ finish }
+ />
+
+ >
+ );
+};
diff --git a/packages/js/src/bulk-editor/components/tour/tour-card.js b/packages/js/src/bulk-editor/components/tour/tour-card.js
new file mode 100644
index 00000000000..2c862e71762
--- /dev/null
+++ b/packages/js/src/bulk-editor/components/tour/tour-card.js
@@ -0,0 +1,146 @@
+import ArrowNarrowRightIcon from "@heroicons/react/solid/ArrowNarrowRightIcon";
+import { createInterpolateElement, useCallback, useEffect, useRef } from "@wordpress/element";
+import { __, sprintf } from "@wordpress/i18n";
+import { Button, Popover, useSvgAria } from "@yoast/ui-library";
+import { ReactComponent as YoastIcon } from "../../../../images/Yoast_icon_kader.svg";
+
+/**
+ * Keeps Tab focus within a container.
+ *
+ * @param {KeyboardEvent} event The Tab keydown event; its `currentTarget` is the container to trap focus inside.
+ *
+ * @returns {void}
+ */
+const trapTab = ( event ) => {
+ const focusable = [ ...event.currentTarget.querySelectorAll(
+ "button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
+ ) ].filter( ( element ) => ! element.disabled );
+ if ( focusable.length === 0 ) {
+ return;
+ }
+ const edge = event.shiftKey ? focusable[ 0 ] : focusable[ focusable.length - 1 ];
+ if ( document.activeElement === edge ) {
+ event.preventDefault();
+ ( event.shiftKey ? focusable[ focusable.length - 1 ] : focusable[ 0 ] ).focus();
+ }
+};
+
+/**
+ * A single step of the bulk editor guided tour, rendered as a ui-library Popover anchored to a target element.
+ *
+ * The card carries the step copy plus the tour navigation (progress, Back, Next/finish). Dismissing the popover ends the whole tour via `onSkip`.
+ *
+ * @param {Object} props The component props.
+ * @param {string} props.id The unique popover id; also derives the title and content ids.
+ * @param {string} props.title The step title.
+ * @param {React.ReactNode} props.content The step body copy.
+ * @param {number} props.currentStep The 1-based index of this step.
+ * @param {number} props.totalSteps The total number of steps.
+ * @param {boolean} props.isLastStep Whether this is the final step (shows "Got it!" instead of "Next").
+ * @param {Function} props.onNext Advances to the next step, or finishes on the last step.
+ * @param {Function} [props.onBack] Goes back a step; omitted on the first step.
+ * @param {Function} props.onSkip Ends the tour without finishing (close button / backdrop / Escape).
+ * @param {string} [props.position] The popover position relative to its anchor.
+ * @param {string} [props.className] Optional extra className for the popover.
+ *
+ * @returns {JSX.Element} The tour step card.
+ */
+export const TourCard = ( {
+ id,
+ title,
+ content,
+ currentStep,
+ totalSteps,
+ isLastStep,
+ onNext,
+ onBack = null,
+ onSkip,
+ position = "right",
+ className = "",
+} ) => {
+ const svgAriaProps = useSvgAria();
+ const nextButtonRef = useRef( null );
+
+ // Move focus to the primary action each time the step changes, so keyboard and screen reader users follow along.
+ useEffect( () => {
+ const timeout = setTimeout( () => nextButtonRef.current?.focus(), 300 );
+ return () => clearTimeout( timeout );
+ }, [ currentStep ] );
+
+ // The close button and Escape both resolve to hiding the popover, which ends the tour.
+ const handleVisibilityChange = useCallback( ( isVisible ) => ! isVisible && onSkip(), [ onSkip ] );
+
+ // The tour blocks the page, so the card behaves as a modal dialog: Escape closes it, and Tab is trapped within its
+ // controls, keeping keyboard users out of the page elements behind it.
+ const handleKeyDown = useCallback( ( event ) => {
+ if ( event.key === "Escape" ) {
+ event.preventDefault();
+ onSkip();
+ } else if ( event.key === "Tab" ) {
+ trapTab( event );
+ }
+ }, [ onSkip ] );
+
+ return
+ <>
+
+
+
+
+
+
+ { sprintf(
+ /* translators: %1$d is the step number, %2$s is the step title. */
+ __( "Step %1$d: %2$s", "wordpress-seo" ),
+ currentStep,
+ title
+ ) }
+
+
+
+
+
+ { content }
+
+
+
+
+ { createInterpolateElement(
+ sprintf(
+ /* translators: %1$s is the current step number, %2$s is the total number of steps. */
+ __( "%1$s / %2$s", "wordpress-seo" ),
+ currentStep,
+ totalSteps
+ ),
+ { current: }
+ ) }
+
+
+
+ { onBack &&
+ { __( "Back", "wordpress-seo" ) }
+ }
+
+ { isLastStep
+ ? __( "Got it!", "wordpress-seo" )
+ : <>
+ { __( "Next", "wordpress-seo" ) }
+
+ > }
+
+
+
+ >
+ ;
+};
diff --git a/packages/js/src/bulk-editor/components/tour/tour-steps.js b/packages/js/src/bulk-editor/components/tour/tour-steps.js
new file mode 100644
index 00000000000..af8858574a9
--- /dev/null
+++ b/packages/js/src/bulk-editor/components/tour/tour-steps.js
@@ -0,0 +1,46 @@
+import { __ } from "@wordpress/i18n";
+
+/**
+ * The bulk editor guided-tour steps, in order.
+ *
+ * Each step points at an element carrying the matching `data-tour-id`. `requiresSelection` marks a step whose
+ * target only exists once rows are selected, so the tour selects rows before showing it.
+ *
+ * @returns {Array} The tour steps.
+ */
+export const getTourSteps = () => [
+ {
+ id: "bulk-editor-tour-content-type",
+ tourId: "content-type-nav",
+ highlightEndSelector: "[data-tour-highlight-end]",
+ position: "right",
+ title: __( "Select your content type", "wordpress-seo" ),
+ content: __( "This will refine the content to what you want to update.", "wordpress-seo" ),
+ },
+ {
+ id: "bulk-editor-tour-appearance",
+ tourId: "appearance-tabs",
+ highlightChildren: true,
+ position: "right",
+ title: __( "Select search or social appearance", "wordpress-seo" ),
+ content: __( "Easily switch between them anytime.", "wordpress-seo" ),
+ },
+ {
+ id: "bulk-editor-tour-multi-select",
+ tourId: "selection-toolbar",
+ highlightChildren: true,
+ position: "right",
+ title: __( "Multi-select", "wordpress-seo" ),
+ content: __( "Choose the rows you want to update. Use the dropdown for additional options.", "wordpress-seo" ),
+ },
+ {
+ id: "bulk-editor-tour-generate",
+ tourId: "generate-actions",
+ highlightChildren: true,
+ highlightChildrenSelector: "button",
+ position: "right",
+ requiresSelection: true,
+ title: __( "Get SEO-friendly options at scale", "wordpress-seo" ),
+ content: __( "Generate up to 20 results at a time. Great for website refreshes!", "wordpress-seo" ),
+ },
+];
diff --git a/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js
new file mode 100644
index 00000000000..df8a920bd2f
--- /dev/null
+++ b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js
@@ -0,0 +1,254 @@
+import { useState, useEffect } from "@wordpress/element";
+
+// How long to keep looking for a target that mounts asynchronously (e.g. the generate actions bar that only appears once
+// rows are selected), and how often to re-check, before giving up on the step.
+const TARGET_WAIT_MS = 2000;
+const TARGET_POLL_MS = 100;
+
+// How many animation frames to keep re-measuring after the tour opens, so a target that shifts while the page settles
+// is followed until it stops moving, then left to the scroll/resize/ResizeObserver listeners.
+const SETTLE_MAX_FRAMES = 40;
+
+// Space (px) around a single-region spotlight so it surrounds the target without looking clipped.
+const SPOTLIGHT_PADDING = { top: 6, right: 6, bottom: 6, left: 6 };
+// Corner radius (px) for a single-region cut-out, which covers an area rather than one styled control.
+const SINGLE_REGION_RADIUS = 8;
+
+/**
+ * Reads the corner radius to match a control's cut-out to its rounded corners. Direct children are sometimes plain
+ * wrappers (radius 0) around the styled control, so it falls back to the first child's radius.
+ *
+ * @param {HTMLElement} element The element.
+ *
+ * @returns {number} The corner radius in pixels.
+ */
+const cornerRadius = ( element ) => {
+ const own = parseFloat( getComputedStyle( element ).borderTopLeftRadius ) || 0;
+ if ( own > 0 ) {
+ return own;
+ }
+ return element.firstElementChild
+ ? parseFloat( getComputedStyle( element.firstElementChild ).borderTopLeftRadius ) || 0
+ : 0;
+};
+
+/**
+ * Finds the visible element for a tour target.
+ *
+ * @param {string} selector The `[data-tour-id="…"]` selector.
+ *
+ * @returns {HTMLElement|null} The visible matching element, or null.
+ */
+const findVisibleTarget = ( selector ) =>
+ [ ...document.querySelectorAll( selector ) ].find( ( element ) => element.offsetParent !== null ) ?? null;
+
+/**
+ * Whether an element exists and is visible.
+ *
+ * @param {HTMLElement} element The element.
+ *
+ * @returns {boolean} Whether it is visible.
+ */
+const isVisible = ( element ) => element.offsetParent !== null && element.getBoundingClientRect().width > 0;
+
+/**
+ * Measures the spotlight rectangles in viewport coordinates for a target.
+ *
+ * @param {HTMLElement} element The target element.
+ * @param {boolean} perChild Whether to spotlight per matching child rather than the whole target.
+ * @param {?string} childSelector With `perChild`, the descendants to spotlight; otherwise every direct child.
+ * @param {?string} endSelector For a single-region spotlight, the element whose bottom clamps its height.
+ *
+ * @returns {Array} The rectangles ({ top, left, width, height, rx }).
+ */
+const measureRects = ( element, perChild, childSelector, endSelector ) => {
+ if ( perChild ) {
+ const children = childSelector ? [ ...element.querySelectorAll( childSelector ) ] : [ ...element.children ];
+ return children.filter( isVisible ).map( ( child ) => {
+ const childRect = child.getBoundingClientRect();
+ return { top: childRect.top, left: childRect.left, width: childRect.width, height: childRect.height, rx: cornerRadius( child ) };
+ } );
+ }
+
+ const rect = element.getBoundingClientRect();
+ const endElement = endSelector ? element.querySelector( endSelector ) : null;
+ const top = rect.top - SPOTLIGHT_PADDING.top;
+ const bottom = endElement ? endElement.getBoundingClientRect().bottom : rect.bottom + SPOTLIGHT_PADDING.bottom;
+ return [ {
+ top,
+ left: rect.left - SPOTLIGHT_PADDING.left,
+ width: rect.width + SPOTLIGHT_PADDING.left + SPOTLIGHT_PADDING.right,
+ height: bottom - top,
+ rx: SINGLE_REGION_RADIUS,
+ } ];
+};
+
+/**
+ * Re-bases rects onto the fixed spotlight overlay. Its origin is not always the viewport's (an RTL document scrollbar
+ * shifts a fixed element's left edge), so subtracting it keeps the cut-outs and card aligned with their targets, which
+ * were measured in viewport coordinates. A no-op when the overlay sits at the origin (the LTR case).
+ *
+ * @param {Array} rects The rectangles in viewport coordinates.
+ *
+ * @returns {Array} The rectangles relative to the overlay.
+ */
+const rebaseToOverlay = ( rects ) => {
+ const overlay = document.querySelector( ".yst-tour-spotlight" );
+ if ( ! overlay ) {
+ return rects;
+ }
+ const { left, top } = overlay.getBoundingClientRect();
+ if ( left === 0 && top === 0 ) {
+ return rects;
+ }
+ return rects.map( ( rect ) => ( { ...rect, left: rect.left - left, top: rect.top - top } ) );
+};
+
+/**
+ * The bounding box enclosing every rectangle.
+ *
+ * @param {Array} rects The rectangles.
+ *
+ * @returns {{top: string, left: string, width: string, height: string}} The bounds.
+ */
+const boundsOf = ( rects ) => {
+ const minLeft = Math.min( ...rects.map( ( rect ) => rect.left ) );
+ const minTop = Math.min( ...rects.map( ( rect ) => rect.top ) );
+ const maxRight = Math.max( ...rects.map( ( rect ) => rect.left + rect.width ) );
+ const maxBottom = Math.max( ...rects.map( ( rect ) => rect.top + rect.height ) );
+ return { top: `${ minTop }px`, left: `${ minLeft }px`, width: `${ maxRight - minLeft }px`, height: `${ maxBottom - minTop }px` };
+};
+
+/**
+ * Tracks a target element and returns the spotlight rectangles.
+ *
+ * @param {string} targetSelector The `[data-tour-id="…"]` selector of the element the step points at.
+ * @param {boolean} isActive Whether this step is the active one; only then is the target measured.
+ * @param {Object} [options] Extra options.
+ * @param {string} [options.endSelector] Selector, resolved within the target, whose bottom clamps a single-region spotlight.
+ * @param {boolean} [options.perChild] Whether to spotlight per visible direct child instead of one for the target.
+ * @param {string} [options.childSelector] With `perChild`, spotlight the matching descendants instead of every direct
+ * child, so slot-filled siblings (e.g. Premium's AI usage counter) stay dimmed.
+ *
+ * @returns {{spotlight: ({rects: Array, bounds: Object, viewport: Object}|null), targetMissing: boolean}} The
+ * spotlight area (or null), and whether the target could not be found before the poll gave up.
+ */
+export const useTourAnchor = ( targetSelector, isActive, { endSelector = null, perChild = false, childSelector = null } = {} ) => {
+ const [ spotlight, setSpotlight ] = useState( null );
+ const [ targetMissing, setTargetMissing ] = useState( false );
+ // Reset the flag synchronously when the step changes, so a stale `true` from the previous step can't make the caller
+ // skip the new one before the effect below re-runs.
+ const [ trackedSelector, setTrackedSelector ] = useState( targetSelector );
+ if ( targetSelector !== trackedSelector ) {
+ setTrackedSelector( targetSelector );
+ setTargetMissing( false );
+ }
+
+ useEffect( () => {
+ if ( ! isActive ) {
+ return;
+ }
+
+ let pollTimer = null;
+ let cleanup = null;
+
+ const attach = ( element ) => {
+ // Instant, minimal scroll: only nudge the target into view if it is off-screen.
+ element.scrollIntoView( { block: "nearest" } );
+
+ // The JSON of the last rendered spotlight; the frequent re-measures only re-render when it actually changed.
+ let renderedKey = "";
+
+ const updatePosition = () => {
+ const rects = measureRects( element, perChild, childSelector, endSelector );
+
+ // No visible children yet (e.g. the generate AI actions row before it mounts): render nothing rather than
+ // an empty, fully-dimmed overlay.
+ if ( rects.length === 0 ) {
+ if ( renderedKey !== "none" ) {
+ renderedKey = "none";
+ setSpotlight( null );
+ }
+ return;
+ }
+
+ const overlaid = rebaseToOverlay( rects );
+ const next = {
+ rects: overlaid,
+ bounds: boundsOf( overlaid ),
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ };
+ const key = JSON.stringify( next );
+ if ( key !== renderedKey ) {
+ renderedKey = key;
+ setSpotlight( next );
+ }
+ };
+ updatePosition();
+
+ window.addEventListener( "scroll", updatePosition, true );
+ window.addEventListener( "resize", updatePosition );
+ const resizeObserver = new ResizeObserver( updatePosition );
+ resizeObserver.observe( element );
+
+ // Premium fills the generate step's slot asynchronously: its AI usage counter arrives after a fetch and, in
+ // RTL, shifts the buttons sideways without changing the container's own size — which the ResizeObserver would
+ // miss. Watch the subtree so an added/updated node re-measures the (now moved) buttons.
+ const mutationObserver = new MutationObserver( updatePosition );
+ mutationObserver.observe( element, { childList: true, subtree: true, attributes: true, characterData: true } );
+
+ // The target can also shift while the page first settles (sidebar/table layout, fonts, the RTL scrollbar),
+ // before any observer fires. Re-measure each frame for a short, capped window; the signature guard keeps this
+ // from re-rendering when nothing changed.
+ let settleFrame = null;
+ let settleTicks = 0;
+ const settle = () => {
+ updatePosition();
+ settleTicks += 1;
+ if ( settleTicks < SETTLE_MAX_FRAMES ) {
+ settleFrame = requestAnimationFrame( settle );
+ }
+ };
+ settleFrame = requestAnimationFrame( settle );
+
+ cleanup = () => {
+ if ( settleFrame !== null ) {
+ cancelAnimationFrame( settleFrame );
+ }
+ window.removeEventListener( "scroll", updatePosition, true );
+ window.removeEventListener( "resize", updatePosition );
+ resizeObserver.disconnect();
+ mutationObserver.disconnect();
+ };
+ };
+
+ // The target may not be in the DOM yet (e.g. the generate action buttons appear only after step 4 selects rows),
+ // so poll briefly until it shows up.
+ const deadline = Date.now() + TARGET_WAIT_MS;
+ const tryAttach = () => {
+ const element = findVisibleTarget( targetSelector );
+ if ( element ) {
+ attach( element );
+ return;
+ }
+ if ( Date.now() < deadline ) {
+ pollTimer = setTimeout( tryAttach, TARGET_POLL_MS );
+ } else {
+ setTargetMissing( true );
+ }
+ };
+ tryAttach();
+
+ return () => {
+ if ( pollTimer ) {
+ clearTimeout( pollTimer );
+ }
+ if ( cleanup ) {
+ cleanup();
+ }
+ setSpotlight( null );
+ };
+ }, [ targetSelector, isActive, endSelector, perChild, childSelector ] );
+
+ return { spotlight, targetMissing };
+};
diff --git a/packages/js/src/bulk-editor/constants.js b/packages/js/src/bulk-editor/constants.js
index d3d35066829..28191d1ccd3 100644
--- a/packages/js/src/bulk-editor/constants.js
+++ b/packages/js/src/bulk-editor/constants.js
@@ -33,6 +33,9 @@ export const BULK_NOTICES_SLOT = "yoast.bulkEditor.bulkNotices";
// The PluginArea scope Premium registers its fills under, so they mount inside this page's React tree.
export const PLUGIN_SCOPE = "yoast-seo-bulk-editor";
+// The opt-in-notification key that tracks whether the first-run guided tour has been seen.
+export const TOUR_OPT_IN_KEY = "bulk_editor_tour";
+
// The filter Premium uses to add items to the Select menu.
export const SELECT_MENU_ITEMS_FILTER = "yoast.bulkEditor.selectMenuItems";
diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js
index 9b2b05b9e6d..aed1891ce7a 100644
--- a/packages/js/src/bulk-editor/initialize.js
+++ b/packages/js/src/bulk-editor/initialize.js
@@ -8,7 +8,7 @@ import { get } from "lodash";
import { createHashRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
import { GenericAlert } from "../ai-generator/components/errors";
import { fixWordPressMenuScrolling } from "../shared-admin/helpers";
-import { LINK_PARAMS_NAME } from "../shared-admin/store";
+import { LINK_PARAMS_NAME, OPT_IN_NOTIFICATION_NAME } from "../shared-admin/store";
import App from "./app";
import { UpsellModal } from "./components/upsell-modal";
import { PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants";
@@ -33,6 +33,9 @@ domReady( () => {
registerStore( {
initialState: {
[ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ),
+ [ OPT_IN_NOTIFICATION_NAME ]: {
+ seen: get( window, "wpseoBulkEditorData.optInNotificationSeen", {} ),
+ },
},
} );
fixWordPressMenuScrolling();
diff --git a/packages/js/src/bulk-editor/store/index.js b/packages/js/src/bulk-editor/store/index.js
index b69d6cf2613..a2da929aa2b 100644
--- a/packages/js/src/bulk-editor/store/index.js
+++ b/packages/js/src/bulk-editor/store/index.js
@@ -1,6 +1,18 @@
import { combineReducers, createReduxStore, register } from "@wordpress/data";
import { merge } from "lodash";
-import { getInitialLinkParamsState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, linkParamsSelectors } from "../../shared-admin/store";
+import {
+ getInitialLinkParamsState,
+ getInitialOptInNotificationState,
+ LINK_PARAMS_NAME,
+ linkParamsActions,
+ linkParamsReducer,
+ linkParamsSelectors,
+ OPT_IN_NOTIFICATION_NAME,
+ optInNotificationActions,
+ optInNotificationControls,
+ optInNotificationReducer,
+ optInNotificationSelectors,
+} from "../../shared-admin/store";
import { STORE_NAME } from "../constants";
import activeContentType, { activeContentTypeActions, activeContentTypeSelectors, createInitialActiveContentTypeState } from "./active-content-type";
import activeFieldSet, { activeFieldSetActions, activeFieldSetSelectors, createInitialActiveFieldSetState } from "./active-field-set";
@@ -39,6 +51,7 @@ const createStore = ( { initialState } ) => {
...externalPendingChangesActions,
...externalGenerationActions,
...pendingSwitchActions,
+ ...optInNotificationActions,
},
selectors: {
...linkParamsSelectors,
@@ -51,6 +64,7 @@ const createStore = ( { initialState } ) => {
...externalPendingChangesSelectors,
...externalGenerationSelectors,
...pendingSwitchSelectors,
+ ...optInNotificationSelectors,
},
initialState: merge(
{},
@@ -65,6 +79,7 @@ const createStore = ( { initialState } ) => {
externalPendingChanges: createInitialExternalPendingChangesState(),
externalGeneration: createInitialExternalGenerationState(),
pendingSwitch: createInitialPendingSwitchState(),
+ [ OPT_IN_NOTIFICATION_NAME ]: getInitialOptInNotificationState(),
},
initialState
),
@@ -79,7 +94,11 @@ const createStore = ( { initialState } ) => {
externalPendingChanges,
externalGeneration,
pendingSwitch,
+ [ OPT_IN_NOTIFICATION_NAME ]: optInNotificationReducer,
} ),
+ controls: {
+ ...optInNotificationControls,
+ },
} );
};
diff --git a/packages/js/src/general/app.js b/packages/js/src/general/app.js
index e37e2500745..dce57f3436d 100644
--- a/packages/js/src/general/app.js
+++ b/packages/js/src/general/app.js
@@ -10,7 +10,7 @@ import { addQueryArgs } from "@wordpress/url";
import { Notifications, SidebarNavigation, useSvgAria } from "@yoast/ui-library";
import PropTypes from "prop-types";
import { Link, Outlet, useLocation } from "react-router-dom";
-import { Notices, OptInContainer } from "./components";
+import { BulkEditorTourNotification, Notices } from "./components";
import { STORE_NAME } from "./constants";
import { deleteMigratingNotices } from "../helpers/migrateNotices";
import { useNotificationCountSync, useSelectGeneralPage } from "./hooks";
@@ -145,7 +145,7 @@ const App = () => {
-
+
{
+ const adminMenuWrap = document.getElementById( "adminmenuwrap" );
+ return ! adminMenuWrap || adminMenuWrap.offsetWidth > 100;
+};
+
+/**
+ * The Dismiss / Show me buttons for the entry notification.
+ *
+ * @param {Object} props The props.
+ * @param {string} props.bulkEditorUrl The bulk editor URL the "Show me" button opens.
+ *
+ * @returns {JSX.Element} The buttons.
+ */
+const NotificationButtons = ( { bulkEditorUrl } ) => {
+ const { handleDismiss } = useModalNotificationContext();
+ const svgAriaProps = useSvgAria();
+
+ // A full-page navigation to the bulk editor, where the guided tour runs.
+ const handleShow = useCallback( () => {
+ window.location.href = bulkEditorUrl;
+ }, [ bulkEditorUrl ] );
+
+ return
+
{ __( "Dismiss", "wordpress-seo" ) }
+
+ { __( "Show me", "wordpress-seo" ) }
+
+
+
;
+};
+
+/**
+ * The bulk editor guided-tour entry notification, shown on the General page.
+ *
+ * It points first-time users to the bulk editor, where the step-by-step tour runs. Dismissing it marks the tour
+ * seen (so it never returns); "Show me" navigates to the bulk editor page.
+ *
+ * @returns {JSX.Element|null} The notification, or nothing when it has been seen or on the first-time configuration.
+ */
+export const BulkEditorTourNotification = () => {
+ const { setOptInNotificationSeen, hideOptInNotification } = useDispatch( STORE_NAME );
+ const svgAriaProps = useSvgAria();
+ const { pathname } = useLocation();
+ const isSeen = useSelectGeneralPage( "selectIsOptInNotificationSeen", [], TOUR_OPT_IN_KEY );
+ const isRtl = useSelectGeneralPage( "selectPreference", [], "isRtl" );
+ const bulkEditorUrl = useSelectGeneralPage( "selectAdminLink", [], BULK_EDITOR_LINK );
+
+ // Dismissing (close button or Dismiss) ends the tour: persist it seen, then hide for the current session.
+ const onClose = useCallback( () => {
+ setOptInNotificationSeen( TOUR_OPT_IN_KEY );
+ hideOptInNotification( TOUR_OPT_IN_KEY );
+ }, [ setOptInNotificationSeen, hideOptInNotification ] );
+
+ const isOpen = ! isSeen && pathname !== ROUTES.firstTimeConfiguration;
+ if ( ! isOpen ) {
+ return null;
+ }
+
+ let positionClass;
+ if ( isAdminSidebarExpanded() ) {
+ positionClass = "md:yst-start-40 rtl:md:yst-start-44";
+ } else if ( isRtl ) {
+ positionClass = "md:yst-start-[3.25rem]";
+ } else {
+ positionClass = "md:yst-start-10";
+ }
+
+ return
+
+
+
+
+ ;
+};
diff --git a/packages/js/src/general/components/index.js b/packages/js/src/general/components/index.js
index f0012904764..7230391e441 100644
--- a/packages/js/src/general/components/index.js
+++ b/packages/js/src/general/components/index.js
@@ -6,8 +6,7 @@ export { Notifications } from "./notifications";
export { Problems } from "./problems";
export { RouteErrorFallback } from "./route-error-fallback";
export { RouteLayout } from "./route-layout";
-export { TaskListOptInNotification } from "./task-list-opt-in-notification";
-export { OptInContainer } from "./opt-in-container";
+export { BulkEditorTourNotification } from "./bulk-editor-tour-notification";
export { TaskListUpsellRow } from "./task-list-upsell-row";
export { Task } from "./task";
export { TaskListModal } from "./task-list-modal";
diff --git a/packages/js/src/general/components/opt-in-container.js b/packages/js/src/general/components/opt-in-container.js
deleted file mode 100644
index e6148fbe931..00000000000
--- a/packages/js/src/general/components/opt-in-container.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { TaskListOptInNotification } from "./task-list-opt-in-notification";
-import { useSelectGeneralPage } from "../hooks";
-import { useLocation } from "react-router-dom";
-import { useCallback } from "@wordpress/element";
-import { useDispatch } from "@wordpress/data";
-import { ROUTES } from "../routes";
-import { STORE_NAME } from "../constants";
-
-/**
- * The container for the opt-in notification.
- * Used to decide whether to show the opt-in notification or not.
- *
- * @returns {JSX.Element|null} The container component.
- */
-export const OptInContainer = () => {
- const taskListOptInNotificationSeen = useSelectGeneralPage( "selectIsOptInNotificationSeen", [], "task_list" );
- const { pathname } = useLocation();
- const { hideOptInNotification } = useDispatch( STORE_NAME );
-
- const isOpen = pathname !== ROUTES.firstTimeConfiguration && ! taskListOptInNotificationSeen && pathname !== ROUTES.taskList;
-
- const onClose = useCallback( () => {
- hideOptInNotification( "task_list" );
- }, [ hideOptInNotification ] );
-
- if ( ! isOpen ) {
- return null;
- }
-
- return ;
-};
diff --git a/packages/js/src/general/components/task-list-opt-in-notification.js b/packages/js/src/general/components/task-list-opt-in-notification.js
deleted file mode 100644
index f79774b43d5..00000000000
--- a/packages/js/src/general/components/task-list-opt-in-notification.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import { ModalNotification, Button, useSvgAria, useModalNotificationContext } from "@yoast/ui-library";
-import { __ } from "@wordpress/i18n";
-import { ReactComponent as YoastIcon } from "../../../images/Yoast_icon_kader.svg";
-import ArrowNarrowRightIcon from "@heroicons/react/outline/ArrowNarrowRightIcon";
-import classNames from "classnames";
-import { useCallback, useEffect } from "@wordpress/element";
-import { STORE_NAME } from "../constants";
-import { useDispatch } from "@wordpress/data";
-import { useNavigate } from "react-router-dom";
-import { ROUTES } from "../routes";
-import PropTypes from "prop-types";
-import { useSelectGeneralPage } from "../hooks";
-
-/**
- * Checks whether the WP admin sidebar is expanded (not collapsed).
- * When collapsed, the sidebar is ~36px wide; when expanded, ~160px.
- *
- * @returns {boolean} True if the admin sidebar is expanded or absent.
- */
-const isAdminSidebarExpanded = () => {
- const adminmenuWrap = document.getElementById( "adminmenuwrap" );
- return ! adminmenuWrap || adminmenuWrap.offsetWidth > 100;
-};
-
-/**
- * The buttons for the task list opt-in notification.
- * Uses the ModalNotification context for dismissal.
- *
- * @returns {JSX.Element} The buttons.
- */
-const NotificationButtons = () => {
- const { handleDismiss } = useModalNotificationContext();
- const svgAriaProps = useSvgAria();
- const taskListpath = ROUTES.taskList;
- const navigate = useNavigate();
- const { hideOptInNotification } = useDispatch( STORE_NAME );
-
- const handleShow = useCallback( async() => {
- hideOptInNotification( "task_list" );
- handleDismiss();
- navigate( taskListpath );
- }, [ taskListpath, navigate ] );
-
- return
-
{ __( "Dismiss", "wordpress-seo" ) }
-
- { __( "Show me", "wordpress-seo" ) }
-
-
-
;
-};
-
-/**
- * The task list opt-in notification component.
- * Uses ModalNotification for accessible focus management and modal behavior.
- *
- * @param {Object} props The component props.
- * @param {boolean} props.isOpen Whether the notification is open.
- * @param {Function} props.onClose Function to call when the notification should close.
- *
- * @returns {JSX.Element} The task list opt-in notification component.
- */
-export const TaskListOptInNotification = ( { isOpen, onClose } ) => {
- const { setOptInNotificationSeen, hideOptInNotification } = useDispatch( STORE_NAME );
- const svgAriaProps = useSvgAria();
-
- useEffect( () => {
- // Mark the notification as seen when mounting.
- setOptInNotificationSeen( "task_list" );
-
- return () => {
- // Hide the notification when unmounting when switching to the FTC tab.
- hideOptInNotification( "task_list" );
- };
- }, [] );
-
- const isRtl = useSelectGeneralPage( "selectPreference", [], "isRtl" );
-
- let notificationPositionClass;
-
- if ( isAdminSidebarExpanded() ) {
- notificationPositionClass = "md:yst-start-40 rtl:md:yst-start-44";
- } else if ( isRtl ) {
- notificationPositionClass = "md:yst-start-[3.25rem]";
- } else {
- notificationPositionClass = "md:yst-start-10";
- }
-
- return
-
-
-
-
- ;
-};
-
-TaskListOptInNotification.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onClose: PropTypes.func.isRequired,
-};
diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js
index 188b76c2b7b..e0542a36562 100644
--- a/packages/js/src/general/initialize.js
+++ b/packages/js/src/general/initialize.js
@@ -10,7 +10,7 @@ import { Dashboard } from "../dashboard";
import { DataProvider } from "../dashboard/services/data-provider";
import { DataTracker } from "../dashboard/services/data-tracker";
import { WidgetFactory } from "../dashboard/services/widget-factory";
-import { ADMIN_URL_NAME, LINK_PARAMS_NAME } from "../shared-admin/store";
+import { ADMIN_URL_NAME, LINK_PARAMS_NAME, OPT_IN_NOTIFICATION_NAME } from "../shared-admin/store";
import App from "./app";
import { RouteErrorFallback } from "./components";
import { ConnectedPremiumUpsellList } from "./components/connected-premium-upsell-list";
@@ -20,7 +20,6 @@ import { AlertCenter, FirstTimeConfiguration, ROUTES, TaskList } from "./routes"
import registerStore from "./store";
import { ADMIN_NOTICES_NAME } from "./store/admin-notices";
import { ALERT_CENTER_NAME } from "./store/alert-center";
-import { OPT_IN_NOTIFICATION_NAME } from "./store/opt-in";
/**
* @type {import("../index").ContentType} ContentType
@@ -45,7 +44,7 @@ domReady( () => {
dismissedAlerts: get( window, "wpseoScriptData.dismissedAlerts", {} ),
isPremium: get( window, "wpseoScriptData.preferences.isPremium", false ),
[ ADMIN_NOTICES_NAME ]: { resolvedNotices: [] },
- [ OPT_IN_NOTIFICATION_NAME ]: { seen: get( window, "wpseoScriptData.optInNotificationSeen", false ) },
+ [ OPT_IN_NOTIFICATION_NAME ]: { seen: get( window, "wpseoScriptData.optInNotificationSeen", {} ) },
[ TASK_LIST_NAME ]: {
enabled: get( window, "wpseoScriptData.taskListConfiguration.enabled", false ),
endpoints: get( window, "wpseoScriptData.taskListConfiguration.endpoints", {} ),
diff --git a/packages/js/src/general/store/index.js b/packages/js/src/general/store/index.js
index 1672e50b485..6fc7d298c69 100644
--- a/packages/js/src/general/store/index.js
+++ b/packages/js/src/general/store/index.js
@@ -9,10 +9,16 @@ import {
adminUrlSelectors,
getInitialAdminUrlState,
getInitialLinkParamsState,
+ getInitialOptInNotificationState,
LINK_PARAMS_NAME,
linkParamsActions,
linkParamsReducer,
linkParamsSelectors,
+ OPT_IN_NOTIFICATION_NAME,
+ optInNotificationActions,
+ optInNotificationControls,
+ optInNotificationReducer,
+ optInNotificationSelectors,
} from "../../shared-admin/store";
import { STORE_NAME } from "../constants";
import { ADMIN_NOTICES_NAME, adminNoticesActions, adminNoticesReducer, adminNoticesSelectors, getInitialAdminNoticesState } from "./admin-notices";
@@ -25,14 +31,6 @@ import {
getInitialAlertCenterState,
} from "./alert-center";
import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences";
-import {
- OPT_IN_NOTIFICATION_NAME,
- optInNotificationActions,
- optInNotificationReducer,
- optInNotificationSelectors,
- optInNotificationControls,
- getInitialOptInNotificationState,
-} from "./opt-in";
import {
TASK_LIST_NAME,
taskListActions,
diff --git a/packages/js/src/settings/components/index.js b/packages/js/src/settings/components/index.js
index ee1c65ef276..05688f670f0 100644
--- a/packages/js/src/settings/components/index.js
+++ b/packages/js/src/settings/components/index.js
@@ -15,7 +15,6 @@ export { default as RouteLayout } from "./route-layout";
export { default as Search } from "./search";
export { ErrorFallback } from "./error-fallback";
export { LlmsTxtAlert } from "./llms-txt-alert";
-export { LlmTxtPopover } from "./llm-txt-popover";
export { LlmsTxtUnsavedChangesModal } from "./llms-txt-unsaved-changes-modal";
export { AdvancedMenu } from "./advanced-menu";
export { SchemaDisableConfirmationModal } from "./schema-disable-confirmation-modal";
diff --git a/packages/js/src/settings/components/llm-txt-popover.js b/packages/js/src/settings/components/llm-txt-popover.js
deleted file mode 100644
index f8ddfe8b365..00000000000
--- a/packages/js/src/settings/components/llm-txt-popover.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { __ } from "@wordpress/i18n";
-import { Popover, usePopoverContext, useSvgAria, Button } from "@yoast/ui-library";
-import { ReactComponent as YoastIcon } from "../../../images/Yoast_icon_kader.svg";
-import { useRef, useEffect, useState } from "@wordpress/element";
-
-/**
- * A button component that dismisses the popover when clicked.
- * On mount, it automatically focuses and scrolls itself into view for better accessibility.
- *
- * @returns {JSX.Element} The dismiss button element.
- */
-const DismissButton = () => {
- const { handleDismiss } = usePopoverContext();
- const dismissButtonText = __( "Got it!", "wordpress-seo" );
- const dismissButtonRef = useRef( null );
-
- // useEffect to focus and scroll to the popover dismiss button
- useEffect( () => {
- const timeout = setTimeout( () => {
- if ( dismissButtonRef.current ) {
- dismissButtonRef.current.focus();
- dismissButtonRef.current.scrollIntoView( { behavior: "smooth", block: "center" } );
- }
- }, 300 );
-
- return () => clearTimeout( timeout );
- }, [] );
-
- return
- { dismissButtonText }
- ;
-};
-
-
-/**
- * The LLM txt popover component for the opt in.
- * @returns {JSX.Element} The LLM txt popover element.
- */
-export const LlmTxtPopover = () => {
- const svgAriaProps = useSvgAria();
- const [ isPopoverVisible, setIsPopoverVisible ] = useState( true );
-
- useEffect( () => {
- sessionStorage?.removeItem( "yoast-highlight-setting" );
- }, [] );
-
- return
- <>
-
-
-
-
-
-
- { __( "Enable the llms.txt feature", "wordpress-seo" ) }
-
-
-
-
-
-
- { __( "Automatically generate an llms.txt file that points LLMs to your site's most relevant content. We also gave you editor access.", "wordpress-seo" ) }
-
-
-
-
-
- >
- ;
-};
diff --git a/packages/js/src/settings/routes/llms-txt.js b/packages/js/src/settings/routes/llms-txt.js
index bd20f516a73..e6cf02010cc 100644
--- a/packages/js/src/settings/routes/llms-txt.js
+++ b/packages/js/src/settings/routes/llms-txt.js
@@ -13,7 +13,6 @@ import {
FormikIndexablePageSelectField,
FormLayout,
RouteLayout,
- LlmTxtPopover,
LlmsTxtAlert,
LlmsTxtUnsavedChangesModal,
} from "../components";
@@ -143,8 +142,6 @@ const LlmTxt = () => {
}
}, [ fetchIndexablePages, isLlmsTxtEnabled, llmsTxtSelectionMode ] );
- const isOptIn = useMemo( () => ! isLlmsTxtEnabled && sessionStorage?.getItem( "yoast-highlight-setting" ) === "llm-txt", [ isLlmsTxtEnabled ] );
-
const [ openUnsavedFile, , , setOpenUnsavedFile, unsetOpenUnsavedFile ] = useToggleState( false );
return (
@@ -158,7 +155,7 @@ const LlmTxt = () => {
{ generationFailure && initialIsLlmsTxtEnabled && isLlmsTxtEnabled && }
-
+
{
),
LABEL
) }
- className={ isOptIn ? "yst-popover-backdrop-highlight-button" : "" }
/>
- { isOptIn && }
{ ! showUnsavedChangesModal &&
( { render: mockRender } ) );
@@ -60,6 +60,7 @@ describe( "bulk editor initialize", () => {
contentTypes: [ { name: "post", label: "Posts" } ],
linkParams: { foo: "bar" },
nonce: "test-nonce",
+ optInNotificationSeen: { [ TOUR_OPT_IN_KEY ]: true },
};
} );
@@ -82,7 +83,10 @@ describe( "bulk editor initialize", () => {
} );
expect( mockRegisterStore ).toHaveBeenCalledWith( {
- initialState: { linkParams: { foo: "bar" } },
+ initialState: {
+ linkParams: { foo: "bar" },
+ optInNotification: { seen: { [ TOUR_OPT_IN_KEY ]: true } },
+ },
} );
expect( mockFixScrolling ).toHaveBeenCalledTimes( 1 );
expect( mockCreateRoot ).toHaveBeenCalledWith( root );
diff --git a/packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js b/packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js
new file mode 100644
index 00000000000..e8bc12ab731
--- /dev/null
+++ b/packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js
@@ -0,0 +1,154 @@
+import { fireEvent, render, screen } from "../../test-utils";
+import { BulkEditorTour } from "../../../src/bulk-editor/components/tour/bulk-editor-tour";
+import { TOUR_OPT_IN_KEY } from "../../../src/bulk-editor/constants";
+
+// Controls the mocked store reads per test.
+const storeState = { isSeen: false, isAiEnabled: true };
+const mockSetOptInNotificationSeen = jest.fn();
+const mockHideOptInNotification = jest.fn();
+
+jest.mock( "@wordpress/data", () => ( {
+ useSelect: ( mapSelect ) => mapSelect( () => ( {
+ selectIsOptInNotificationSeen: () => storeState.isSeen,
+ selectPreference: ( key, fallback ) => ( key === "isAiEnabled" ? storeState.isAiEnabled : fallback ),
+ } ) ),
+ useDispatch: () => ( { setOptInNotificationSeen: mockSetOptInNotificationSeen, hideOptInNotification: mockHideOptInNotification } ),
+} ) );
+
+const mockAnchor = { spotlight: null, targetMissing: false };
+const defaultSpotlight = () => ( {
+ rects: [ { top: 0, left: 0, width: 10, height: 10 } ],
+ bounds: { top: "0px", left: "0px", width: "10px", height: "10px" },
+ viewport: { width: 1024, height: 768 },
+} );
+
+jest.mock( "../../../src/bulk-editor/components/tour/use-tour-anchor", () => ( {
+ useTourAnchor: () => mockAnchor,
+} ) );
+
+/**
+ * Renders the tour and returns the select/deselect spies plus a re-render helper.
+ *
+ * @param {Object} [props] Prop overrides.
+ * @returns {{onSelectAll: jest.Mock, onDeselectAll: jest.Mock, rerender: Function}} The spies and a re-render helper.
+ */
+const renderTour = ( props = {} ) => {
+ const onSelectAll = jest.fn();
+ const onDeselectAll = jest.fn();
+ const makeElement = () => (
+
+ );
+ const view = render( makeElement() );
+ // A fresh element each time, so React actually re-renders (it bails out on an identical element reference).
+ return { onSelectAll, onDeselectAll, rerender: () => view.rerender( makeElement() ) };
+};
+
+const clickNext = () => fireEvent.click( screen.getByRole( "button", { name: /Next/ } ) );
+
+// The step counter emphasises the current number in its own span, so match the counter wrapper's full text.
+const expectStepCounter = ( text ) => expect( screen.getByText(
+ ( _content, element ) => element?.classList?.contains?.( "yst-ms-8" ) && element.textContent.replace( /\s+/g, " " ).trim() === text
+) ).toBeInTheDocument();
+
+describe( "BulkEditorTour", () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ storeState.isSeen = false;
+ storeState.isAiEnabled = true;
+ mockAnchor.spotlight = defaultSpotlight();
+ mockAnchor.targetMissing = false;
+ } );
+
+ it( "renders nothing once the tour has been seen", () => {
+ storeState.isSeen = true;
+ renderTour();
+
+ expect( screen.queryByRole( "dialog" ) ).not.toBeInTheDocument();
+ } );
+
+ it( "auto-starts on the first step for a new user", () => {
+ renderTour();
+
+ expectStepCounter( "1 / 4" );
+ expect( screen.getByRole( "heading", { name: "Step 1: Select your content type" } ) ).toBeInTheDocument();
+ } );
+
+ it( "advances and goes back through the steps", () => {
+ renderTour();
+
+ clickNext();
+ expectStepCounter( "2 / 4" );
+
+ fireEvent.click( screen.getByRole( "button", { name: "Back" } ) );
+ expectStepCounter( "1 / 4" );
+ } );
+
+ it( "drops the generate step when AI is disabled, leaving three steps", () => {
+ storeState.isAiEnabled = false;
+ renderTour();
+
+ clickNext();
+ clickNext();
+
+ expectStepCounter( "3 / 3" );
+ expect( screen.queryByText( "Get SEO-friendly options at scale" ) ).not.toBeInTheDocument();
+ } );
+
+ it( "selects rows when reaching the generate step so its target can appear", () => {
+ const { onSelectAll } = renderTour( { hasSelection: false } );
+
+ // Advance through steps 2 and 3 to the generate step (step 4), which requires a selection.
+ clickNext();
+ clickNext();
+ clickNext();
+
+ expect( onSelectAll ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( "does not re-select when the user already has a selection", () => {
+ const { onSelectAll } = renderTour( { hasSelection: true } );
+
+ clickNext();
+ clickNext();
+ clickNext();
+
+ expect( onSelectAll ).not.toHaveBeenCalled();
+ } );
+
+ it( "finishes and marks the tour seen if the last step's target never appears", () => {
+ const { onDeselectAll, rerender } = renderTour( { hasSelection: false } );
+
+ // Reach the generate step (step 4).
+ clickNext();
+ clickNext();
+ clickNext();
+
+ // Its target never shows (e.g. nothing is selectable), so the anchor reports it missing.
+ mockAnchor.spotlight = null;
+ mockAnchor.targetMissing = true;
+ rerender();
+
+ expect( mockSetOptInNotificationSeen ).toHaveBeenCalledWith( TOUR_OPT_IN_KEY );
+ expect( onDeselectAll ).toHaveBeenCalledTimes( 1 );
+ expect( screen.queryByRole( "dialog" ) ).not.toBeInTheDocument();
+ } );
+
+ it( "marks the tour seen and clears its own selection on finish", () => {
+ const { onDeselectAll } = renderTour( { hasSelection: false } );
+
+ clickNext();
+ clickNext();
+ clickNext();
+ fireEvent.click( screen.getByRole( "button", { name: "Got it!" } ) );
+
+ expect( mockSetOptInNotificationSeen ).toHaveBeenCalledWith( TOUR_OPT_IN_KEY );
+ expect( mockHideOptInNotification ).toHaveBeenCalledWith( TOUR_OPT_IN_KEY );
+ expect( onDeselectAll ).toHaveBeenCalledTimes( 1 );
+ expect( screen.queryByRole( "dialog" ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/packages/js/tests/bulk-editor/tour/tour-card.test.js b/packages/js/tests/bulk-editor/tour/tour-card.test.js
new file mode 100644
index 00000000000..8b2ab0ae5a0
--- /dev/null
+++ b/packages/js/tests/bulk-editor/tour/tour-card.test.js
@@ -0,0 +1,74 @@
+import { fireEvent, render, screen } from "../../test-utils";
+import { TourCard } from "../../../src/bulk-editor/components/tour/tour-card";
+
+const baseProps = {
+ id: "tour-step",
+ title: "Select your content type",
+ content: "This will refine the content.",
+ currentStep: 1,
+ totalSteps: 4,
+ isLastStep: false,
+ onNext: jest.fn(),
+ onSkip: jest.fn(),
+};
+
+// The step counter emphasises the current number in its own span, so match the counter wrapper's full text.
+const expectStepCounter = ( text ) => expect( screen.getByText(
+ ( _content, element ) => element?.classList?.contains?.( "yst-ms-8" ) && element.textContent.replace( /\s+/g, " " ).trim() === text
+) ).toBeInTheDocument();
+
+describe( "TourCard", () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( "renders the title, content and step progress", () => {
+ render( );
+
+ expect( screen.getByRole( "heading", { name: "Step 1: Select your content type" } ) ).toBeInTheDocument();
+ expect( screen.getByText( "This will refine the content." ) ).toBeInTheDocument();
+ expectStepCounter( "1 / 4" );
+ } );
+
+ it( "calls onNext when the Next button is clicked", () => {
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: /Next/ } ) );
+
+ expect( baseProps.onNext ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( "omits the Back button on the first step and shows it with onBack", () => {
+ const { rerender } = render( );
+ expect( screen.queryByRole( "button", { name: "Back" } ) ).not.toBeInTheDocument();
+
+ const onBack = jest.fn();
+ rerender( );
+ fireEvent.click( screen.getByRole( "button", { name: "Back" } ) );
+
+ expect( onBack ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( "shows the finish label on the last step instead of Next", () => {
+ render( );
+
+ expect( screen.getByRole( "button", { name: "Got it!" } ) ).toBeInTheDocument();
+ expect( screen.queryByRole( "button", { name: /Next/ } ) ).not.toBeInTheDocument();
+ } );
+
+ it( "ends the tour via onSkip when the close button is clicked", () => {
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: "Close the tour" } ) );
+
+ expect( baseProps.onSkip ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( "ends the tour via onSkip when Escape is pressed", () => {
+ render( );
+
+ fireEvent.keyDown( screen.getByRole( "dialog" ), { key: "Escape" } );
+
+ expect( baseProps.onSkip ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/packages/js/tests/bulk-editor/tour/tour-steps.test.js b/packages/js/tests/bulk-editor/tour/tour-steps.test.js
new file mode 100644
index 00000000000..cb98c0859cc
--- /dev/null
+++ b/packages/js/tests/bulk-editor/tour/tour-steps.test.js
@@ -0,0 +1,34 @@
+import { getTourSteps } from "../../../src/bulk-editor/components/tour/tour-steps";
+
+describe( "getTourSteps", () => {
+ it( "returns the four tour steps in order", () => {
+ const steps = getTourSteps();
+
+ expect( steps.map( ( step ) => step.tourId ) ).toEqual( [
+ "content-type-nav",
+ "appearance-tabs",
+ "selection-toolbar",
+ "generate-actions",
+ ] );
+ } );
+
+ it( "gives every step a unique id, a title and content", () => {
+ const steps = getTourSteps();
+ const ids = steps.map( ( step ) => step.id );
+
+ expect( new Set( ids ).size ).toBe( steps.length );
+ steps.forEach( ( step ) => {
+ expect( typeof step.title ).toBe( "string" );
+ expect( step.title.length ).toBeGreaterThan( 0 );
+ expect( typeof step.content ).toBe( "string" );
+ expect( step.content.length ).toBeGreaterThan( 0 );
+ } );
+ } );
+
+ it( "marks only the generate step as requiring a selection", () => {
+ const requiresSelection = getTourSteps().filter( ( step ) => step.requiresSelection );
+
+ expect( requiresSelection ).toHaveLength( 1 );
+ expect( requiresSelection[ 0 ].tourId ).toBe( "generate-actions" );
+ } );
+} );
diff --git a/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js b/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js
new file mode 100644
index 00000000000..94cce6f5627
--- /dev/null
+++ b/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js
@@ -0,0 +1,187 @@
+import { act, renderHook } from "@testing-library/react";
+import { useTourAnchor } from "../../../src/bulk-editor/components/tour/use-tour-anchor";
+
+let resizeObserverDisconnect;
+
+/**
+ * Stubs an element's layout, since jsdom has none.
+ *
+ * @param {HTMLElement} element The element.
+ * @param {Object} rect The bounding rect it should report.
+ *
+ * @returns {HTMLElement} The element.
+ */
+const stubLayout = ( element, rect ) => {
+ // findVisibleTarget/isVisible skip elements whose offsetParent is null; jsdom always reports null.
+ Object.defineProperty( element, "offsetParent", { get: () => document.body, configurable: true } );
+ element.getBoundingClientRect = () => ( { ...rect, right: rect.left + rect.width, bottom: rect.top + rect.height } );
+ return element;
+};
+
+/**
+ * Adds a tour target to the DOM with a stubbed layout.
+ *
+ * @param {Object} rect The bounding rect the target should report.
+ *
+ * @returns {HTMLElement} The target element.
+ */
+const addTarget = ( rect = { top: 10, left: 20, width: 100, height: 30 } ) => {
+ document.body.innerHTML = "
";
+ return stubLayout( document.querySelector( "[data-tour-id=\"x\"]" ), rect );
+};
+
+/**
+ * Appends a stubbed child to a target.
+ *
+ * @param {HTMLElement} target The target.
+ * @param {Object} rect The child's bounding rect.
+ *
+ * @returns {HTMLElement} The child.
+ */
+const addChild = ( target, rect ) => {
+ const child = document.createElement( "div" );
+ target.appendChild( child );
+ return stubLayout( child, rect );
+};
+
+describe( "useTourAnchor", () => {
+ beforeEach( () => {
+ Element.prototype.scrollIntoView = jest.fn();
+ resizeObserverDisconnect = jest.fn();
+ global.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {
+ resizeObserverDisconnect();
+ }
+ };
+ } );
+
+ afterEach( () => {
+ document.body.innerHTML = "";
+ } );
+
+ it( "returns no spotlight when inactive", () => {
+ addTarget();
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", false ) );
+
+ expect( result.current.spotlight ).toBeNull();
+ } );
+
+ it( "cuts one padded rectangle for the whole target when active", () => {
+ const target = addTarget( { top: 10, left: 20, width: 100, height: 30 } );
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true ) );
+
+ // The target rect grows by the uniform 6px spotlight padding on every side.
+ expect( result.current.spotlight.rects ).toEqual( [ { top: 4, left: 14, width: 112, height: 42, rx: 8 } ] );
+ expect( result.current.spotlight.bounds ).toEqual( { top: "4px", left: "14px", width: "112px", height: "42px" } );
+ expect( result.current.spotlight.viewport ).toEqual( { width: window.innerWidth, height: window.innerHeight } );
+ expect( target.scrollIntoView ).toHaveBeenCalled();
+ } );
+
+ it( "re-bases the spotlight onto a shifted overlay (RTL scrollbar)", () => {
+ addTarget( { top: 10, left: 20, width: 100, height: 30 } );
+
+ // Simulate an RTL document where the left-side scrollbar shifts the fixed overlay's origin 15px inward.
+ const overlay = document.createElement( "div" );
+ overlay.className = "yst-tour-spotlight";
+ overlay.getBoundingClientRect = () => ( { left: 15, top: 0, width: 0, height: 0 } );
+ document.body.appendChild( overlay );
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true ) );
+
+ // The padded rect (left 20 - 6 = 14) is re-based by the overlay's 15px offset, so it lands on its control.
+ expect( result.current.spotlight.rects[ 0 ].left ).toBe( -1 );
+ expect( result.current.spotlight.rects[ 0 ].top ).toBe( 4 );
+ expect( result.current.spotlight.bounds.left ).toBe( "-1px" );
+
+ overlay.remove();
+ } );
+
+ it( "clamps a single-region rectangle to the end element's bottom", () => {
+ const target = addTarget( { top: 10, left: 20, width: 100, height: 90 } );
+ const end = addChild( target, { top: 40, left: 20, width: 100, height: 20 } );
+ end.setAttribute( "data-tour-highlight-end", "true" );
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true, { endSelector: "[data-tour-highlight-end]" } ) );
+
+ // Height spans from the padded top (4) to the end element's bottom (60), not the target's own 90-tall rect.
+ expect( result.current.spotlight.rects ).toEqual( [ { top: 4, left: 14, width: 112, height: 56, rx: 8 } ] );
+ } );
+
+ it( "cuts one rectangle per visible child with perChild", () => {
+ const target = addTarget( { top: 10, left: 20, width: 200, height: 30 } );
+ addChild( target, { top: 12, left: 22, width: 16, height: 16 } );
+ addChild( target, { top: 10, left: 60, width: 80, height: 28 } );
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true, { perChild: true } ) );
+
+ // Each cut-out matches its child's exact size (no padding), so the gap between them stays dimmed.
+ expect( result.current.spotlight.rects ).toEqual( [
+ { top: 12, left: 22, width: 16, height: 16, rx: 0 },
+ { top: 10, left: 60, width: 80, height: 28, rx: 0 },
+ ] );
+ // The bounds union spans both children.
+ expect( result.current.spotlight.bounds ).toEqual( { top: "10px", left: "22px", width: "118px", height: "28px" } );
+ } );
+
+ it( "cuts one rectangle per matching descendant with childSelector, leaving siblings dimmed", () => {
+ const target = addTarget( { top: 10, left: 20, width: 300, height: 30 } );
+ // A non-button sibling (e.g. Premium's AI usage counter) that must stay dimmed.
+ addChild( target, { top: 12, left: 22, width: 40, height: 16 } );
+ const button = document.createElement( "button" );
+ target.appendChild( button );
+ stubLayout( button, { top: 10, left: 100, width: 80, height: 28 } );
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true, { perChild: true, childSelector: "button" } ) );
+
+ // Only the button is cut out; the sibling counter keeps no hole.
+ expect( result.current.spotlight.rects ).toEqual( [ { top: 10, left: 100, width: 80, height: 28, rx: 0 } ] );
+ } );
+
+ it( "returns no spotlight for perChild when the target has no visible children", () => {
+ addTarget();
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true, { perChild: true } ) );
+
+ expect( result.current.spotlight ).toBeNull();
+ } );
+
+ it( "disconnects the observer on cleanup", () => {
+ addTarget();
+
+ const { unmount } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true ) );
+ unmount();
+
+ expect( resizeObserverDisconnect ).toHaveBeenCalled();
+ } );
+
+ it( "returns no spotlight when the target is absent", () => {
+ document.body.innerHTML = "";
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"missing\"]", true ) );
+
+ expect( result.current.spotlight ).toBeNull();
+ } );
+
+ it( "flags the target as missing once the poll gives up", () => {
+ jest.useFakeTimers();
+ try {
+ document.body.innerHTML = "";
+
+ const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"missing\"]", true ) );
+ expect( result.current.targetMissing ).toBe( false );
+
+ // Advance past the 2s poll window so it stops looking and reports the target missing.
+ act( () => {
+ jest.advanceTimersByTime( 2500 );
+ } );
+
+ expect( result.current.targetMissing ).toBe( true );
+ } finally {
+ jest.useRealTimers();
+ }
+ } );
+} );
diff --git a/packages/js/tests/general/components/bulk-editor-tour-notification.test.js b/packages/js/tests/general/components/bulk-editor-tour-notification.test.js
new file mode 100644
index 00000000000..551248c51ac
--- /dev/null
+++ b/packages/js/tests/general/components/bulk-editor-tour-notification.test.js
@@ -0,0 +1,105 @@
+import { fireEvent, render, screen, waitFor } from "../../test-utils";
+import { BulkEditorTourNotification } from "../../../src/general/components/bulk-editor-tour-notification";
+import { ROUTES } from "../../../src/general/routes";
+
+const mockSetOptInNotificationSeen = jest.fn();
+const mockHideOptInNotification = jest.fn();
+
+// Controls the mocked store/router reads per test.
+const state = {
+ isSeen: false,
+ isRtl: false,
+ pathname: "/",
+ bulkEditorUrl: "https://example.com/wp-admin/admin.php?page=wpseo_page_bulk_edit",
+};
+
+jest.mock( "@wordpress/data", () => ( {
+ useDispatch: () => ( {
+ setOptInNotificationSeen: mockSetOptInNotificationSeen,
+ hideOptInNotification: mockHideOptInNotification,
+ } ),
+} ) );
+
+jest.mock( "react-router-dom", () => ( {
+ useLocation: () => ( { pathname: state.pathname } ),
+} ) );
+
+jest.mock( "../../../src/general/hooks", () => ( {
+ useSelectGeneralPage: ( selector ) => {
+ switch ( selector ) {
+ case "selectIsOptInNotificationSeen":
+ return state.isSeen;
+ case "selectPreference":
+ return state.isRtl;
+ case "selectAdminLink":
+ return state.bulkEditorUrl;
+ default:
+ return undefined;
+ }
+ },
+} ) );
+
+// Stub the routes barrel so the test does not pull in the whole routes/notices tree (which drags in withSelect etc.).
+jest.mock( "../../../src/general/routes", () => ( {
+ ROUTES: { firstTimeConfiguration: "/first-time-configuration" },
+} ) );
+
+describe( "BulkEditorTourNotification", () => {
+ let originalLocation;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ state.isSeen = false;
+ state.isRtl = false;
+ state.pathname = "/";
+ originalLocation = window.location;
+ Object.defineProperty( window, "location", { configurable: true, value: { href: "" } } );
+ } );
+
+ afterEach( () => {
+ Object.defineProperty( window, "location", { configurable: true, value: originalLocation } );
+ } );
+
+ it( "renders nothing once the tour has been seen", () => {
+ state.isSeen = true;
+ render( );
+
+ expect( screen.queryByText( "New: Work faster with bulk updates" ) ).not.toBeInTheDocument();
+ } );
+
+ it( "renders nothing on the first-time configuration route", () => {
+ state.pathname = ROUTES.firstTimeConfiguration;
+ render( );
+
+ expect( screen.queryByText( "New: Work faster with bulk updates" ) ).not.toBeInTheDocument();
+ } );
+
+ it( "shows the title, message and actions for a new user", () => {
+ render( );
+
+ expect( screen.getByText( "New: Work faster with bulk updates" ) ).toBeInTheDocument();
+ expect( screen.getByText( /Use the Bulk Editor to get AI-generated/ ) ).toBeInTheDocument();
+ // The footer Dismiss button carries visible text; the modal's icon-only close only has an aria-label.
+ expect( screen.getByText( "Dismiss" ) ).toBeInTheDocument();
+ expect( screen.getByRole( "button", { name: /Show me/ } ) ).toBeInTheDocument();
+ } );
+
+ it( "marks the tour seen and hides it when dismissed", async() => {
+ render( );
+
+ fireEvent.click( screen.getByText( "Dismiss" ) );
+
+ await waitFor( () => {
+ expect( mockSetOptInNotificationSeen ).toHaveBeenCalledWith( "bulk_editor_tour" );
+ } );
+ expect( mockHideOptInNotification ).toHaveBeenCalledWith( "bulk_editor_tour" );
+ } );
+
+ it( "navigates to the bulk editor when Show me is clicked", () => {
+ render( );
+
+ fireEvent.click( screen.getByRole( "button", { name: /Show me/ } ) );
+
+ expect( window.location.href ).toBe( state.bulkEditorUrl );
+ } );
+} );
diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php
index 1c0b5776d5d..edad4484cc0 100644
--- a/src/bulk-editor/user-interface/bulk-editor-integration.php
+++ b/src/bulk-editor/user-interface/bulk-editor-integration.php
@@ -13,6 +13,7 @@
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Product_Helper;
use Yoast\WP\SEO\Helpers\Short_Link_Helper;
+use Yoast\WP\SEO\Helpers\User_Helper;
use Yoast\WP\SEO\Integrations\Integration_Interface;
/**
@@ -86,6 +87,13 @@ class Bulk_Editor_Integration implements Integration_Interface {
*/
private $options_helper;
+ /**
+ * Holds the User_Helper.
+ *
+ * @var User_Helper
+ */
+ private $user_helper;
+
/**
* Constructs the instance.
*
@@ -97,6 +105,7 @@ class Bulk_Editor_Integration implements Integration_Interface {
* @param Nonce_Repository $nonce_repository The Nonce_Repository.
* @param Endpoints_Repository $endpoints_repository The Endpoints_Repository.
* @param Options_Helper $options_helper The Options_Helper.
+ * @param User_Helper $user_helper The User_Helper.
*/
public function __construct(
WPSEO_Admin_Asset_Manager $asset_manager,
@@ -106,7 +115,8 @@ public function __construct(
Content_Types_Repository $content_types_repository,
Nonce_Repository $nonce_repository,
Endpoints_Repository $endpoints_repository,
- Options_Helper $options_helper
+ Options_Helper $options_helper,
+ User_Helper $user_helper
) {
$this->asset_manager = $asset_manager;
$this->current_page_helper = $current_page_helper;
@@ -116,6 +126,7 @@ public function __construct(
$this->nonce_repository = $nonce_repository;
$this->endpoints_repository = $endpoints_repository;
$this->options_helper = $options_helper;
+ $this->user_helper = $user_helper;
}
/**
@@ -206,31 +217,46 @@ public function enqueue_assets() {
/**
* Creates the script data.
*
- * @return array>> The script data.
+ * @return array|array>> The script data.
*/
public function get_script_data() {
return [
- 'contentTypes' => $this->content_types_repository->get_content_types(),
- 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(),
+ 'contentTypes' => $this->content_types_repository->get_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
// wp_validate_redirect() here before exposing it, to avoid an open redirect on the front-end.
- 'links' => [
+ 'links' => [
'dashboard' => \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ),
'tools' => \admin_url( 'admin.php?page=wpseo_tools' ),
],
- 'nonce' => $this->nonce_repository->get_rest_nonce(),
- 'restRoot' => \esc_url_raw( \rest_url() ),
- 'preferences' => [
+ 'nonce' => $this->nonce_repository->get_rest_nonce(),
+ 'restRoot' => \esc_url_raw( \rest_url() ),
+ 'preferences' => [
'isPremium' => $this->product_helper->is_premium(),
'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true,
'isRtl' => \is_rtl(),
'pluginUrl' => \plugins_url( '', \WPSEO_FILE ),
],
- 'linkParams' => $this->short_link_helper->get_query_params(),
+ 'linkParams' => $this->short_link_helper->get_query_params(),
+ // Whether the first-run guided tour has already been seen, so it only shows once per user.
+ 'optInNotificationSeen' => [
+ 'bulk_editor_tour' => $this->is_tour_opt_in_notification_seen(),
+ ],
];
}
+ /**
+ * Gets whether the bulk editor guided tour has been seen by the current user.
+ *
+ * @return bool True when the tour has been seen, false otherwise.
+ */
+ private function is_tour_opt_in_notification_seen(): bool {
+ $current_user_id = $this->user_helper->get_current_user_id();
+
+ return (bool) $this->user_helper->get_meta( $current_user_id, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true );
+ }
+
/**
* Removes all current WP notices.
*
diff --git a/src/general/user-interface/general-page-integration.php b/src/general/user-interface/general-page-integration.php
index cc994224617..7e22d436421 100644
--- a/src/general/user-interface/general-page-integration.php
+++ b/src/general/user-interface/general-page-integration.php
@@ -93,18 +93,18 @@ class General_Page_Integration implements Integration_Interface {
private $alert_dismissal_action;
/**
- * Holds the user helper.
+ * Holds the options helper.
*
- * @var User_Helper
+ * @var Options_Helper
*/
- private $user_helper;
+ private $options_helper;
/**
- * Holds the options helper.
+ * Holds the user helper.
*
- * @var Options_Helper
+ * @var User_Helper
*/
- private $options_helper;
+ private $user_helper;
/**
* Holds the WooCommerce conditional.
@@ -131,8 +131,8 @@ class General_Page_Integration implements Integration_Interface {
* @param Alert_Dismissal_Action $alert_dismissal_action The alert dismissal action.
* @param Promotion_Manager $promotion_manager The promotion manager.
* @param Dashboard_Configuration $dashboard_configuration The dashboard configuration.
- * @param User_Helper $user_helper The user helper.
* @param Options_Helper $options_helper The options helper.
+ * @param User_Helper $user_helper The user helper.
* @param WooCommerce_Conditional $woocommerce_conditional The WooCommerce conditional.
* @param WPSEO_Addon_Manager $addon_manager The WPSEO_Addon_Manager.
* @param Task_List_Configuration $task_list_configuration The task list configuration.
@@ -146,8 +146,8 @@ public function __construct(
Alert_Dismissal_Action $alert_dismissal_action,
Promotion_Manager $promotion_manager,
Dashboard_Configuration $dashboard_configuration,
- User_Helper $user_helper,
Options_Helper $options_helper,
+ User_Helper $user_helper,
WooCommerce_Conditional $woocommerce_conditional,
WPSEO_Addon_Manager $addon_manager,
Task_List_Configuration $task_list_configuration
@@ -160,8 +160,8 @@ public function __construct(
$this->alert_dismissal_action = $alert_dismissal_action;
$this->promotion_manager = $promotion_manager;
$this->dashboard_configuration = $dashboard_configuration;
- $this->user_helper = $user_helper;
$this->options_helper = $options_helper;
+ $this->user_helper = $user_helper;
$this->woocommerce_conditional = $woocommerce_conditional;
$this->addon_manager = $addon_manager;
$this->task_list_configuration = $task_list_configuration;
@@ -250,7 +250,7 @@ public function enqueue_assets() {
/**
* Creates the script data.
*
- * @return array The script data.
+ * @return array>> The script data.
*/
private function get_script_data() {
return [
@@ -280,20 +280,21 @@ private function get_script_data() {
'currentPromotions' => $this->promotion_manager->get_current_promotions(),
'dismissedAlerts' => $this->alert_dismissal_action->all_dismissed(),
'dashboard' => $this->dashboard_configuration->get_configuration(),
+ 'taskListConfiguration' => $this->task_list_configuration->get_configuration(),
'optInNotificationSeen' => [
- 'task_list' => $this->is_task_list_opt_in_notification_seen(),
+ 'bulk_editor_tour' => $this->is_bulk_editor_tour_seen(),
],
- 'taskListConfiguration' => $this->task_list_configuration->get_configuration(),
];
}
/**
- * Gets if the llms.txt opt-in notification has been seen.
+ * Gets whether the bulk editor guided tour has been seen by the current user.
*
- * @return bool True if the notification has been seen, false otherwise.
+ * @return bool True when the tour has been seen, false otherwise.
*/
- private function is_task_list_opt_in_notification_seen(): bool {
+ private function is_bulk_editor_tour_seen(): bool {
$current_user_id = $this->user_helper->get_current_user_id();
- return (bool) $this->user_helper->get_meta( $current_user_id, '_yoast_wpseo_task_list_opt_in_notification_seen', true );
+
+ return (bool) $this->user_helper->get_meta( $current_user_id, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true );
}
}
diff --git a/src/general/user-interface/opt-in-route.php b/src/general/user-interface/opt-in-route.php
index da68ae89426..da56604a4e5 100644
--- a/src/general/user-interface/opt-in-route.php
+++ b/src/general/user-interface/opt-in-route.php
@@ -142,7 +142,7 @@ public function can_see_opt_in() {
*/
public function validate_key( $key ) {
$allowed_keys = [
- 'task_list',
+ 'bulk_editor_tour',
];
return \in_array( $key, $allowed_keys, true );
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php
index 40e506e0c95..9cc1778264e 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php
@@ -14,6 +14,7 @@
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Product_Helper;
use Yoast\WP\SEO\Helpers\Short_Link_Helper;
+use Yoast\WP\SEO\Helpers\User_Helper;
use Yoast\WP\SEO\Tests\Unit\TestCase;
/**
@@ -86,6 +87,13 @@ abstract class Abstract_Bulk_Editor_Integration_Test extends TestCase {
*/
protected $options_helper;
+ /**
+ * Holds the User_Helper mock.
+ *
+ * @var Mockery\MockInterface|User_Helper
+ */
+ protected $user_helper;
+
/**
* Sets up the test fixtures.
*
@@ -102,6 +110,7 @@ protected function set_up() {
$this->nonce_repository = Mockery::mock( Nonce_Repository::class );
$this->endpoints_repository = Mockery::mock( Endpoints_Repository::class );
$this->options_helper = Mockery::mock( Options_Helper::class );
+ $this->user_helper = Mockery::mock( User_Helper::class );
$this->instance = new Bulk_Editor_Integration(
$this->asset_manager,
@@ -112,6 +121,7 @@ protected function set_up() {
$this->nonce_repository,
$this->endpoints_repository,
$this->options_helper,
+ $this->user_helper,
);
}
}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php
index 553e7e195fd..a1a62666bb8 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php
@@ -12,6 +12,7 @@
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Product_Helper;
use Yoast\WP\SEO\Helpers\Short_Link_Helper;
+use Yoast\WP\SEO\Helpers\User_Helper;
/**
* Tests the Bulk_Editor_Integration constructor.
@@ -60,5 +61,9 @@ public function test_constructor() {
Options_Helper::class,
$this->getPropertyValue( $this->instance, 'options_helper' ),
);
+ $this->assertInstanceOf(
+ User_Helper::class,
+ $this->getPropertyValue( $this->instance, 'user_helper' ),
+ );
}
}
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 aa5ee5052a2..25ee7d20a75 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
@@ -37,23 +37,26 @@ public function test_enqueue_assets() {
];
$expected_script_data = [
- 'contentTypes' => $content_types,
- 'endpoints' => [
+ 'contentTypes' => $content_types,
+ 'endpoints' => [
'posts' => 'https://example.com/wp-json/yoast/v1/bulk_editor/posts',
],
- 'links' => [
+ 'links' => [
'dashboard' => 'https://example.com/wp-admin/admin.php?page=wpseo_dashboard',
'tools' => 'https://example.com/wp-admin/admin.php?page=wpseo_tools',
],
- 'nonce' => 'rest-nonce',
- 'restRoot' => 'https://example.com/wp-json/',
- 'preferences' => [
+ 'nonce' => 'rest-nonce',
+ 'restRoot' => 'https://example.com/wp-json/',
+ 'preferences' => [
'isPremium' => false,
'isAiEnabled' => true,
'isRtl' => false,
'pluginUrl' => 'https://example.com/wp-content/plugins/wordpress-seo',
],
- 'linkParams' => [ 'foo' => 'bar' ],
+ 'linkParams' => [ 'foo' => 'bar' ],
+ 'optInNotificationSeen' => [
+ 'bulk_editor_tour' => false,
+ ],
];
Actions\expectRemoved( 'admin_print_scripts' )->once()->with( 'print_emoji_detection_script' );
@@ -84,6 +87,11 @@ static function ( $path ) {
},
);
$this->short_link_helper->expects( 'get_query_params' )->once()->andReturn( [ 'foo' => 'bar' ] );
+ $this->user_helper->expects( 'get_current_user_id' )->once()->andReturn( 1 );
+ $this->user_helper->expects( 'get_meta' )
+ ->once()
+ ->with( 1, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true )
+ ->andReturn( '' );
$this->asset_manager->expects( 'localize_script' )
->once()
diff --git a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php
index 419028e1298..704392e8a80 100644
--- a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php
+++ b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php
@@ -95,18 +95,18 @@ final class General_Page_Integration_Test extends TestCase {
protected $instance;
/**
- * Holds the user helper mock.
+ * Holds the options helper mock.
*
- * @var Mockery\MockInterface|User_Helper
+ * @var Mockery\MockInterface|Options_Helper
*/
- private $user_helper;
+ private $options_helper;
/**
- * Holds the options helper mock.
+ * Holds the user helper mock.
*
- * @var Mockery\MockInterface|Options_Helper
+ * @var Mockery\MockInterface|User_Helper
*/
- private $options_helper;
+ private $user_helper;
/**
* Holds the WooCommerce conditional.
@@ -145,8 +145,8 @@ public function set_up() {
$this->alert_dismissal_action = Mockery::mock( Alert_Dismissal_Action::class );
$this->promotion_manager = Mockery::mock( Promotion_Manager::class );
$this->dashboard_configuration = Mockery::mock( Dashboard_Configuration::class );
- $this->user_helper = Mockery::mock( User_Helper::class );
$this->options_helper = Mockery::mock( Options_Helper::class );
+ $this->user_helper = Mockery::mock( User_Helper::class );
$this->woocommerce_conditional = Mockery::mock( WooCommerce_Conditional::class );
$this->addon_manager = Mockery::mock( WPSEO_Addon_Manager::class );
$this->task_list_configuration = Mockery::mock( Task_List_Configuration::class );
@@ -160,8 +160,8 @@ public function set_up() {
$this->alert_dismissal_action,
$this->promotion_manager,
$this->dashboard_configuration,
- $this->user_helper,
$this->options_helper,
+ $this->user_helper,
$this->woocommerce_conditional,
$this->addon_manager,
$this->task_list_configuration,
@@ -187,8 +187,8 @@ public function test_construct() {
$this->alert_dismissal_action,
$this->promotion_manager,
$this->dashboard_configuration,
- $this->user_helper,
$this->options_helper,
+ $this->user_helper,
$this->woocommerce_conditional,
$this->addon_manager,
$this->task_list_configuration,
@@ -309,7 +309,6 @@ public function test_display_page() {
*
* @covers ::enqueue_assets
* @covers ::get_script_data
- * @covers ::is_task_list_opt_in_notification_seen
*
* @return void
*/
@@ -349,22 +348,21 @@ public function test_enqueue_assets() {
->with( 'black-friday-banner' )
->once();
+ $this->options_helper
+ ->expects( 'get' )
+ ->with( 'enable_llms_txt', true )
+ ->once()
+ ->andReturn( false );
+
$this->user_helper
->expects( 'get_current_user_id' )
->once()
->andReturn( 1 );
-
$this->user_helper
->expects( 'get_meta' )
- ->with( 1, '_yoast_wpseo_task_list_opt_in_notification_seen', true )
- ->once()
- ->andReturn( false );
-
- $this->options_helper
- ->expects( 'get' )
- ->with( 'enable_llms_txt', true )
+ ->with( 1, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true )
->once()
- ->andReturn( false );
+ ->andReturn( '' );
$this->woocommerce_conditional
->expects( 'is_met' )
diff --git a/tests/Unit/General/User_Interface/Opt_In_Route/Validate_Key_Test.php b/tests/Unit/General/User_Interface/Opt_In_Route/Validate_Key_Test.php
index 4bdfc52c5dc..603f25c9e12 100644
--- a/tests/Unit/General/User_Interface/Opt_In_Route/Validate_Key_Test.php
+++ b/tests/Unit/General/User_Interface/Opt_In_Route/Validate_Key_Test.php
@@ -19,7 +19,16 @@ final class Validate_Key_Test extends Abstract_Opt_In_Route_Test {
* @return void
*/
public function test_validate_key_with_valid_key() {
- $this->assertTrue( $this->instance->validate_key( 'task_list' ) );
+ $this->assertTrue( $this->instance->validate_key( 'bulk_editor_tour' ) );
+ }
+
+ /**
+ * Tests that the removed task-list key is no longer valid.
+ *
+ * @return void
+ */
+ public function test_validate_key_rejects_removed_task_list_key() {
+ $this->assertFalse( $this->instance->validate_key( 'task_list' ) );
}
/**
@@ -64,7 +73,7 @@ public function test_validate_key_with_partial_match() {
* @return void
*/
public function test_validate_key_with_extra_characters() {
- $this->assertFalse( $this->instance->validate_key( 'task_list_extra' ) );
+ $this->assertFalse( $this->instance->validate_key( 'bulk_editor_tour_extra' ) );
}
/**
@@ -73,7 +82,7 @@ public function test_validate_key_with_extra_characters() {
* @return void
*/
public function test_validate_key_is_case_sensitive() {
- $this->assertFalse( $this->instance->validate_key( 'TASK_LIST' ) );
+ $this->assertFalse( $this->instance->validate_key( 'BULK_EDITOR_TOUR' ) );
}
/**
@@ -82,6 +91,6 @@ public function test_validate_key_is_case_sensitive() {
* @return void
*/
public function test_validate_key_with_whitespace() {
- $this->assertFalse( $this->instance->validate_key( ' task_list ' ) );
+ $this->assertFalse( $this->instance->validate_key( ' bulk_editor_tour ' ) );
}
}
diff --git a/tests/WP/General/User_Interface/Set_Opt_In_Seen_Test.php b/tests/WP/General/User_Interface/Set_Opt_In_Seen_Test.php
index 10ed9bfe278..0816be6ed57 100644
--- a/tests/WP/General/User_Interface/Set_Opt_In_Seen_Test.php
+++ b/tests/WP/General/User_Interface/Set_Opt_In_Seen_Test.php
@@ -53,7 +53,7 @@ public function test_set_opt_in_seen_with_valid_key_and_privileged_user() {
\wp_set_current_user( $user->ID );
$request = new WP_REST_Request( 'POST', '/yoast/v1/seen-opt-in-notification' );
- $request->set_param( 'key', 'task_list' );
+ $request->set_param( 'key', 'bulk_editor_tour' );
$response = \rest_get_server()->dispatch( $request );
@@ -65,7 +65,7 @@ public function test_set_opt_in_seen_with_valid_key_and_privileged_user() {
$this->assertTrue( $response_data->success );
$this->assertSame( 200, $response_data->status );
- $meta_value = \get_user_meta( $user->ID, '_yoast_wpseo_task_list_opt_in_notification_seen', true );
+ $meta_value = \get_user_meta( $user->ID, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true );
$this->assertSame( $meta_value, '1' );
}
@@ -103,7 +103,7 @@ public function test_set_opt_in_seen_with_not_privileged_user() {
\wp_set_current_user( $user->ID );
$request = new WP_REST_Request( 'POST', '/yoast/v1/seen-opt-in-notification' );
- $request->set_param( 'key', 'task_list' );
+ $request->set_param( 'key', 'bulk_editor_tour' );
$response = \rest_get_server()->dispatch( $request );
@@ -124,7 +124,7 @@ public function test_set_opt_in_seen_with_no_user() {
\wp_set_current_user( 0 );
$request = new WP_REST_Request( 'POST', '/yoast/v1/seen-opt-in-notification' );
- $request->set_param( 'key', 'task_list' );
+ $request->set_param( 'key', 'bulk_editor_tour' );
$response = \rest_get_server()->dispatch( $request );
@@ -170,7 +170,7 @@ public function test_set_opt_in_seen_is_idempotent() {
\wp_set_current_user( $user->ID );
$request = new WP_REST_Request( 'POST', '/yoast/v1/seen-opt-in-notification' );
- $request->set_param( 'key', 'task_list' );
+ $request->set_param( 'key', 'bulk_editor_tour' );
// First call: sets the meta.
$response = \rest_get_server()->dispatch( $request );