From 73c0c4554663d47f435eab8c0bd53dbaecfd6410 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Thu, 23 Jul 2026 16:52:53 +0200 Subject: [PATCH 01/20] Enhances Bulk Editor Integration with User Helper --- packages/js/src/general/store/opt-in.js | 64 ------------------- .../bulk-editor-integration.php | 28 +++++++- src/general/user-interface/opt-in-route.php | 1 + .../Abstract_Bulk_Editor_Integration_Test.php | 10 +++ .../Constructor_Test.php | 5 ++ .../Enqueue_Assets_Test.php | 8 +++ .../Opt_In_Route/Validate_Key_Test.php | 9 +++ 7 files changed, 60 insertions(+), 65 deletions(-) delete mode 100644 packages/js/src/general/store/opt-in.js diff --git a/packages/js/src/general/store/opt-in.js b/packages/js/src/general/store/opt-in.js deleted file mode 100644 index 9658e7ca856..00000000000 --- a/packages/js/src/general/store/opt-in.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; -import { get } from "lodash"; -import { ASYNC_ACTION_NAMES } from "../../shared-admin/constants"; -import apiFetch from "@wordpress/api-fetch"; - -export const OPT_IN_NOTIFICATION_NAME = "optInNotification"; - -const OPT_IN_NOTIFICATION_SEEN = "setOptInNotificationSeen"; - -/** - * @param {string} key The key of the notification to set as seen. - * @returns {Object} Success or error action object. - */ -function* setOptInNotificationSeen( key ) { - yield{ type: `${ OPT_IN_NOTIFICATION_SEEN }/${ ASYNC_ACTION_NAMES.request }` }; - try { - yield{ - type: OPT_IN_NOTIFICATION_SEEN, - payload: key, - }; - return { type: `${ OPT_IN_NOTIFICATION_SEEN }/${ ASYNC_ACTION_NAMES.success }`, payload: key }; - } catch ( error ) { - console.error( "Error setting opt-in notification as seen:", error ); - return { type: `${ OPT_IN_NOTIFICATION_SEEN }/${ ASYNC_ACTION_NAMES.error }`, payload: key }; - } -} - -const slice = createSlice( { - name: OPT_IN_NOTIFICATION_NAME, - initialState: { seen: {} }, - reducers: { - hideOptInNotification( state, action ) { - const key = action.payload; - state.seen[ key ] = true; - }, - }, -} ); - -/** - * @returns {Object} The initial state. - */ -export const getInitialOptInNotificationState = slice.getInitialState; - - -export const optInNotificationSelectors = { - selectIsOptInNotificationSeen: ( state, key ) => get( state, [ OPT_IN_NOTIFICATION_NAME, "seen", key ], false ), -}; - -export const optInNotificationActions = { - ...slice.actions, - setOptInNotificationSeen, -}; - -export const optInNotificationControls = { - [ OPT_IN_NOTIFICATION_SEEN ]: async( { payload } ) => { - await apiFetch( { - path: "yoast/v1/seen-opt-in-notification", - method: "POST", - data: { key: payload }, - } ); - }, -}; - -export const optInNotificationReducer = slice.reducer; diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index 1c0b5776d5d..e919db1dc43 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; } /** @@ -228,9 +239,24 @@ public function get_script_data() { 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ), ], '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/opt-in-route.php b/src/general/user-interface/opt-in-route.php index da68ae89426..84957cea6f7 100644 --- a/src/general/user-interface/opt-in-route.php +++ b/src/general/user-interface/opt-in-route.php @@ -143,6 +143,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..22a6cf465e6 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 @@ -54,6 +54,9 @@ public function test_enqueue_assets() { 'pluginUrl' => 'https://example.com/wp-content/plugins/wordpress-seo', ], '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/Opt_In_Route/Validate_Key_Test.php b/tests/Unit/General/User_Interface/Opt_In_Route/Validate_Key_Test.php index 4bdfc52c5dc..5f2c281a708 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 @@ -22,6 +22,15 @@ public function test_validate_key_with_valid_key() { $this->assertTrue( $this->instance->validate_key( 'task_list' ) ); } + /** + * Tests the validate_key method with the bulk editor tour key. + * + * @return void + */ + public function test_validate_key_with_bulk_editor_tour_key() { + $this->assertTrue( $this->instance->validate_key( 'bulk_editor_tour' ) ); + } + /** * Tests the validate_key method with an invalid key. * From 9d78d8eece8f749f5865674a11e8e94c46996ffd Mon Sep 17 00:00:00 2001 From: JordiPV Date: Fri, 24 Jul 2026 09:50:23 +0200 Subject: [PATCH 02/20] Implement opt-in notification feature in bulk editor --- packages/js/src/bulk-editor/constants.js | 3 + packages/js/src/bulk-editor/initialize.js | 5 +- packages/js/src/bulk-editor/store/index.js | 21 +++- packages/js/src/general/app.js | 3 +- packages/js/src/general/components/index.js | 2 - .../general/components/opt-in-container.js | 31 ----- .../task-list-opt-in-notification.js | 117 ------------------ packages/js/src/general/initialize.js | 2 - packages/js/src/general/store/index.js | 13 -- packages/js/src/shared-admin/store/index.js | 1 + packages/js/src/shared-admin/store/opt-in.js | 64 ++++++++++ .../js/tests/bulk-editor/initialize.test.js | 8 +- 12 files changed, 99 insertions(+), 171 deletions(-) delete mode 100644 packages/js/src/general/components/opt-in-container.js delete mode 100644 packages/js/src/general/components/task-list-opt-in-notification.js create mode 100644 packages/js/src/shared-admin/store/opt-in.js 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..672ce08c2b4 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 { Notices } from "./components"; import { STORE_NAME } from "./constants"; import { deleteMigratingNotices } from "../helpers/migrateNotices"; import { useNotificationCountSync, useSelectGeneralPage } from "./hooks"; @@ -145,7 +145,6 @@ const App = () => { - { - 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
- - -
; -}; - -/** - * 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..607e333f25e 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -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,6 @@ 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 ) }, [ 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..aa83ba9a060 100644 --- a/packages/js/src/general/store/index.js +++ b/packages/js/src/general/store/index.js @@ -25,14 +25,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, @@ -64,7 +56,6 @@ const createStore = ( { initialState } ) => { setDismissedAlerts, setIsPremium, ...adminNoticesActions, - ...optInNotificationActions, ...taskListActions, }, selectors: { @@ -76,7 +67,6 @@ const createStore = ( { initialState } ) => { getIsPremium, isPromotionActive, ...adminNoticesSelectors, - ...optInNotificationSelectors, ...taskListSelectors, }, initialState: merge( @@ -88,7 +78,6 @@ const createStore = ( { initialState } ) => { [ ALERT_CENTER_NAME ]: getInitialAlertCenterState(), currentPromotions: { promotions: [] }, [ ADMIN_NOTICES_NAME ]: getInitialAdminNoticesState(), - [ OPT_IN_NOTIFICATION_NAME ]: getInitialOptInNotificationState(), [ TASK_LIST_NAME ]: getInitialTaskListState(), }, initialState @@ -102,13 +91,11 @@ const createStore = ( { initialState } ) => { dismissedAlerts, isPremium, [ ADMIN_NOTICES_NAME ]: adminNoticesReducer, - [ OPT_IN_NOTIFICATION_NAME ]: optInNotificationReducer, [ TASK_LIST_NAME ]: taskListReducer, } ), controls: { ...alertCenterControls, ...dismissedAlertsControls, - ...optInNotificationControls, ...taskListControls, }, } ); diff --git a/packages/js/src/shared-admin/store/index.js b/packages/js/src/shared-admin/store/index.js index 898bfe90021..b8ca4eede15 100644 --- a/packages/js/src/shared-admin/store/index.js +++ b/packages/js/src/shared-admin/store/index.js @@ -2,6 +2,7 @@ export * from "./admin-url"; export * from "./ai-generator-has-consent"; export * from "./link-params"; export * from "./notifications"; +export * from "./opt-in"; export * from "./plugin-url"; export * from "./wistia-embed-permission"; export * from "./document-title"; diff --git a/packages/js/src/shared-admin/store/opt-in.js b/packages/js/src/shared-admin/store/opt-in.js new file mode 100644 index 00000000000..cfb51bf24f4 --- /dev/null +++ b/packages/js/src/shared-admin/store/opt-in.js @@ -0,0 +1,64 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { get } from "lodash"; +import { ASYNC_ACTION_NAMES } from "../constants"; +import apiFetch from "@wordpress/api-fetch"; + +export const OPT_IN_NOTIFICATION_NAME = "optInNotification"; + +const OPT_IN_NOTIFICATION_SEEN = "setOptInNotificationSeen"; + +/** + * @param {string} key The key of the notification to set as seen. + * @returns {Object} Success or error action object. + */ +function* setOptInNotificationSeen( key ) { + yield{ type: `${ OPT_IN_NOTIFICATION_SEEN }/${ ASYNC_ACTION_NAMES.request }` }; + try { + yield{ + type: OPT_IN_NOTIFICATION_SEEN, + payload: key, + }; + return { type: `${ OPT_IN_NOTIFICATION_SEEN }/${ ASYNC_ACTION_NAMES.success }`, payload: key }; + } catch ( error ) { + console.error( "Error setting opt-in notification as seen:", error ); + return { type: `${ OPT_IN_NOTIFICATION_SEEN }/${ ASYNC_ACTION_NAMES.error }`, payload: key }; + } +} + +const slice = createSlice( { + name: OPT_IN_NOTIFICATION_NAME, + initialState: { seen: {} }, + reducers: { + hideOptInNotification( state, action ) { + const key = action.payload; + state.seen[ key ] = true; + }, + }, +} ); + +/** + * @returns {Object} The initial state. + */ +export const getInitialOptInNotificationState = slice.getInitialState; + + +export const optInNotificationSelectors = { + selectIsOptInNotificationSeen: ( state, key ) => get( state, [ OPT_IN_NOTIFICATION_NAME, "seen", key ], false ), +}; + +export const optInNotificationActions = { + ...slice.actions, + setOptInNotificationSeen, +}; + +export const optInNotificationControls = { + [ OPT_IN_NOTIFICATION_SEEN ]: async( { payload } ) => { + await apiFetch( { + path: "yoast/v1/seen-opt-in-notification", + method: "POST", + data: { key: payload }, + } ); + }, +}; + +export const optInNotificationReducer = slice.reducer; diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js index dc2621a62db..36ed88d5065 100644 --- a/packages/js/tests/bulk-editor/initialize.test.js +++ b/packages/js/tests/bulk-editor/initialize.test.js @@ -1,5 +1,5 @@ import { jest } from "@jest/globals"; -import { ROOT_ID } from "../../src/bulk-editor/constants"; +import { ROOT_ID, TOUR_OPT_IN_KEY } from "../../src/bulk-editor/constants"; const mockRender = jest.fn(); const mockCreateRoot = jest.fn( () => ( { 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 ); From 2c395b65dea76bad49c78ebdfaf6783efb060704 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Fri, 24 Jul 2026 09:51:43 +0200 Subject: [PATCH 03/20] Refactor General Page Integration and Opt-In Route by removing the 'task_list' key in favor of 'bulk_editor_tour' --- .../general-page-integration.php | 24 ------------------- src/general/user-interface/opt-in-route.php | 1 - .../General_Page_Integration_Test.php | 23 ------------------ .../Opt_In_Route/Validate_Key_Test.php | 14 +++++------ .../User_Interface/Set_Opt_In_Seen_Test.php | 10 ++++---- 5 files changed, 12 insertions(+), 60 deletions(-) diff --git a/src/general/user-interface/general-page-integration.php b/src/general/user-interface/general-page-integration.php index cc994224617..6d4a2fc6464 100644 --- a/src/general/user-interface/general-page-integration.php +++ b/src/general/user-interface/general-page-integration.php @@ -14,7 +14,6 @@ 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; use Yoast\WP\SEO\Promotions\Application\Promotion_Manager; use Yoast\WP\SEO\Task_List\Application\Configuration\Task_List_Configuration; @@ -92,13 +91,6 @@ class General_Page_Integration implements Integration_Interface { */ private $alert_dismissal_action; - /** - * Holds the user helper. - * - * @var User_Helper - */ - private $user_helper; - /** * Holds the options helper. * @@ -131,7 +123,6 @@ 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 WooCommerce_Conditional $woocommerce_conditional The WooCommerce conditional. * @param WPSEO_Addon_Manager $addon_manager The WPSEO_Addon_Manager. @@ -146,7 +137,6 @@ 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, WooCommerce_Conditional $woocommerce_conditional, WPSEO_Addon_Manager $addon_manager, @@ -160,7 +150,6 @@ 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->woocommerce_conditional = $woocommerce_conditional; $this->addon_manager = $addon_manager; @@ -280,20 +269,7 @@ 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(), - 'optInNotificationSeen' => [ - 'task_list' => $this->is_task_list_opt_in_notification_seen(), - ], 'taskListConfiguration' => $this->task_list_configuration->get_configuration(), ]; } - - /** - * Gets if the llms.txt opt-in notification has been seen. - * - * @return bool True if the notification has been seen, false otherwise. - */ - private function is_task_list_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_task_list_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 84957cea6f7..da56604a4e5 100644 --- a/src/general/user-interface/opt-in-route.php +++ b/src/general/user-interface/opt-in-route.php @@ -142,7 +142,6 @@ public function can_see_opt_in() { */ public function validate_key( $key ) { $allowed_keys = [ - 'task_list', 'bulk_editor_tour', ]; 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..f92ab0db3c3 100644 --- a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php +++ b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php @@ -17,7 +17,6 @@ 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\Promotions\Application\Promotion_Manager; use Yoast\WP\SEO\Task_List\Application\Configuration\Task_List_Configuration; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -94,13 +93,6 @@ final class General_Page_Integration_Test extends TestCase { */ protected $instance; - /** - * Holds the user helper mock. - * - * @var Mockery\MockInterface|User_Helper - */ - private $user_helper; - /** * Holds the options helper mock. * @@ -145,7 +137,6 @@ 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->woocommerce_conditional = Mockery::mock( WooCommerce_Conditional::class ); $this->addon_manager = Mockery::mock( WPSEO_Addon_Manager::class ); @@ -160,7 +151,6 @@ public function set_up() { $this->alert_dismissal_action, $this->promotion_manager, $this->dashboard_configuration, - $this->user_helper, $this->options_helper, $this->woocommerce_conditional, $this->addon_manager, @@ -187,7 +177,6 @@ public function test_construct() { $this->alert_dismissal_action, $this->promotion_manager, $this->dashboard_configuration, - $this->user_helper, $this->options_helper, $this->woocommerce_conditional, $this->addon_manager, @@ -309,7 +298,6 @@ public function test_display_page() { * * @covers ::enqueue_assets * @covers ::get_script_data - * @covers ::is_task_list_opt_in_notification_seen * * @return void */ @@ -349,17 +337,6 @@ public function test_enqueue_assets() { ->with( 'black-friday-banner' ) ->once(); - $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 ) 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 5f2c281a708..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,16 +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 the validate_key method with the bulk editor tour key. + * Tests that the removed task-list key is no longer valid. * * @return void */ - public function test_validate_key_with_bulk_editor_tour_key() { - $this->assertTrue( $this->instance->validate_key( 'bulk_editor_tour' ) ); + public function test_validate_key_rejects_removed_task_list_key() { + $this->assertFalse( $this->instance->validate_key( 'task_list' ) ); } /** @@ -73,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' ) ); } /** @@ -82,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' ) ); } /** @@ -91,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 ); From 7b852791013ab3f30a064caf582e6ca42015c2b3 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Fri, 24 Jul 2026 15:42:22 +0200 Subject: [PATCH 04/20] Refines the guided tour feature for the bulk editor --- css/src/bulk-editor-page.css | 14 +++ .../bulk-editor/components/bulk-action-bar.js | 4 +- .../components/bulk-editor-content.js | 2 + .../bulk-editor/components/bulk-editor-nav.js | 30 ++--- .../components/bulk-editor-tabs.js | 2 +- .../components/tour/bulk-editor-tour.js | 98 +++++++++++++++ .../bulk-editor/components/tour/tour-card.js | 103 +++++++++++++++ .../bulk-editor/components/tour/tour-steps.js | 41 ++++++ .../components/tour/use-tour-anchor.js | 92 ++++++++++++++ packages/js/src/general/app.js | 3 +- .../bulk-editor-tour-notification.js | 116 +++++++++++++++++ packages/js/src/general/components/index.js | 1 + packages/js/src/general/initialize.js | 3 +- packages/js/src/general/store/index.js | 11 ++ .../bulk-editor/tour/bulk-editor-tour.test.js | 119 ++++++++++++++++++ .../tests/bulk-editor/tour/tour-card.test.js | 61 +++++++++ .../tests/bulk-editor/tour/tour-steps.test.js | 34 +++++ .../bulk-editor/tour/use-tour-anchor.test.js | 74 +++++++++++ .../general-page-integration.php | 25 ++++ .../General_Page_Integration_Test.php | 21 ++++ 20 files changed, 835 insertions(+), 19 deletions(-) create mode 100644 packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js create mode 100644 packages/js/src/bulk-editor/components/tour/tour-card.js create mode 100644 packages/js/src/bulk-editor/components/tour/tour-steps.js create mode 100644 packages/js/src/bulk-editor/components/tour/use-tour-anchor.js create mode 100644 packages/js/src/general/components/bulk-editor-tour-notification.js create mode 100644 packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js create mode 100644 packages/js/tests/bulk-editor/tour/tour-card.test.js create mode 100644 packages/js/tests/bulk-editor/tour/tour-steps.test.js create mode 100644 packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index e5d5aadce3e..4d249b6461a 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -6,6 +6,11 @@ padding-left: 0 !important; } + /* Keep the admin menu above the guided-tour spotlight dim so it isn't overlayed. */ + #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) { @@ -107,3 +112,12 @@ .yst-root .yst-bulk-editor-title-link:focus:not(:focus-visible) { @apply yst-outline-none; } + +/* The guided-tour spotlight: lifts the highlighted element above a viewport-filling shadow that dims the rest + * of the page, so the current step's target stays bright. The shadow roves as the class moves each step. */ +.yst-root .yst-feature-highlight { + position: relative; + z-index: 10; /* Above page content (z:auto); the tour card wrapper (z:100000) stays above the shadow. */ + box-shadow: 0 0 0 100vmax rgb(100 116 139 / 0.75); /* Slate-500/75 spotlight dim. */ + border-radius: 0.5rem; /* Soften the bright cut-out's corners. */ +} diff --git a/packages/js/src/bulk-editor/components/bulk-action-bar.js b/packages/js/src/bulk-editor/components/bulk-action-bar.js index 94c37cd4658..c7d76f8626a 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -79,7 +79,7 @@ export const SelectionToolbar = ( { idSuffix = "", isAllSelected, isIndeterminat }, [ isIndeterminate ] ); return ( -
+
( -
+
{ ! isPremium && isAiEnabled && } { isActive && ( 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..2aec41ec3a4 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"; @@ -225,6 +226,7 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy onCancel: onCancelSwitch, } } /> +
); }; 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..9e745acf261 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-nav.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-nav.js @@ -121,20 +121,22 @@ export const BulkEditorNavMenu = ( {
- - { contentTypes.map( ( contentType ) => ( - - ) ) } - +
+ + { contentTypes.map( ( contentType ) => ( + + ) ) } + +
); }; 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 } = 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 { style } = useTourAnchor( `[data-tour-id="${ step.tourId }"]`, isActive ); + + const finish = useCallback( () => { + if ( didAutoSelect.current ) { + onDeselectAll(); + didAutoSelect.current = false; + } + setIsDismissed( true ); + setOptInNotificationSeen( TOUR_OPT_IN_KEY ); + }, [ onDeselectAll, setOptInNotificationSeen ] ); + + const onNext = useCallback( () => { + setStepIndex( ( index ) => { + if ( index >= steps.length - 1 ) { + finish(); + return index; + } + return index + 1; + } ); + }, [ steps.length, finish ] ); + + const onBack = useCallback( () => setStepIndex( ( index ) => Math.max( 0, index - 1 ) ), [] ); + + if ( ! isActive || ! style ) { + return null; + } + + return ( +
+ 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..36cb1c3a37e --- /dev/null +++ b/packages/js/src/bulk-editor/components/tour/tour-card.js @@ -0,0 +1,103 @@ +import ArrowNarrowRightIcon from "@heroicons/react/solid/ArrowNarrowRightIcon"; +import { 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"; + +/** + * 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, backdrop and Escape all resolve to hiding the popover, which ends the tour. + const handleVisibilityChange = useCallback( ( isVisible ) => ! isVisible && onSkip(), [ onSkip ] ); + + return + <> +
+
+ +
+
+ + { title } + +
+ +
+ + { content } + +
+ + { 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 + ) } + +
+ { onBack && } + +
+
+ +
; +}; 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..dd5eb7e9b01 --- /dev/null +++ b/packages/js/src/bulk-editor/components/tour/tour-steps.js @@ -0,0 +1,41 @@ +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", + 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", + 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", + 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", + position: "bottom-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..be14f0d08bc --- /dev/null +++ b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js @@ -0,0 +1,92 @@ +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; + +/** + * 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; + +/** + * Positions the tour card against a target element and moves the spotlight highlight onto it. + * + * @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 and highlighted. + * + * @returns {{style: Object|null}} The wrapper's absolute-position style, or null while no target is found. + */ +export const useTourAnchor = ( targetSelector, isActive ) => { + const [ style, setStyle ] = useState( null ); + + useEffect( () => { + if ( ! isActive ) { + return; + } + + let pollTimer = null; + let cleanup = null; + + const attach = ( element ) => { + element.classList.add( "yst-feature-highlight" ); + element.scrollIntoView( { behavior: "smooth", block: "center" } ); + + const updatePosition = () => { + const rect = element.getBoundingClientRect(); + setStyle( { + top: `${ rect.top }px`, + left: `${ rect.left }px`, + width: `${ rect.width }px`, + height: `${ rect.height }px`, + } ); + }; + updatePosition(); + + window.addEventListener( "scroll", updatePosition, true ); + window.addEventListener( "resize", updatePosition ); + const observer = new ResizeObserver( updatePosition ); + observer.observe( element ); + + cleanup = () => { + element.classList.remove( "yst-feature-highlight" ); + window.removeEventListener( "scroll", updatePosition, true ); + window.removeEventListener( "resize", updatePosition ); + observer.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 ); + } + }; + tryAttach(); + + return () => { + if ( pollTimer ) { + clearTimeout( pollTimer ); + } + if ( cleanup ) { + cleanup(); + } + setStyle( null ); + }; + }, [ targetSelector, isActive ] ); + + return { style }; +}; diff --git a/packages/js/src/general/app.js b/packages/js/src/general/app.js index 672ce08c2b4..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 } from "./components"; +import { BulkEditorTourNotification, Notices } from "./components"; import { STORE_NAME } from "./constants"; import { deleteMigratingNotices } from "../helpers/migrateNotices"; import { useNotificationCountSync, useSelectGeneralPage } from "./hooks"; @@ -145,6 +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
+ + +
; +}; + +/** + * 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 b6178d18ea3..7230391e441 100644 --- a/packages/js/src/general/components/index.js +++ b/packages/js/src/general/components/index.js @@ -6,6 +6,7 @@ export { Notifications } from "./notifications"; export { Problems } from "./problems"; export { RouteErrorFallback } from "./route-error-fallback"; export { RouteLayout } from "./route-layout"; +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/initialize.js b/packages/js/src/general/initialize.js index 607e333f25e..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"; @@ -44,6 +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", {} ) }, [ 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 aa83ba9a060..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"; @@ -56,6 +62,7 @@ const createStore = ( { initialState } ) => { setDismissedAlerts, setIsPremium, ...adminNoticesActions, + ...optInNotificationActions, ...taskListActions, }, selectors: { @@ -67,6 +74,7 @@ const createStore = ( { initialState } ) => { getIsPremium, isPromotionActive, ...adminNoticesSelectors, + ...optInNotificationSelectors, ...taskListSelectors, }, initialState: merge( @@ -78,6 +86,7 @@ const createStore = ( { initialState } ) => { [ ALERT_CENTER_NAME ]: getInitialAlertCenterState(), currentPromotions: { promotions: [] }, [ ADMIN_NOTICES_NAME ]: getInitialAdminNoticesState(), + [ OPT_IN_NOTIFICATION_NAME ]: getInitialOptInNotificationState(), [ TASK_LIST_NAME ]: getInitialTaskListState(), }, initialState @@ -91,11 +100,13 @@ const createStore = ( { initialState } ) => { dismissedAlerts, isPremium, [ ADMIN_NOTICES_NAME ]: adminNoticesReducer, + [ OPT_IN_NOTIFICATION_NAME ]: optInNotificationReducer, [ TASK_LIST_NAME ]: taskListReducer, } ), controls: { ...alertCenterControls, ...dismissedAlertsControls, + ...optInNotificationControls, ...taskListControls, }, } ); 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..ad3df3389aa --- /dev/null +++ b/packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js @@ -0,0 +1,119 @@ +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(); + +jest.mock( "@wordpress/data", () => ( { + useSelect: ( mapSelect ) => mapSelect( () => ( { + selectIsOptInNotificationSeen: () => storeState.isSeen, + selectPreference: ( key, fallback ) => ( key === "isAiEnabled" ? storeState.isAiEnabled : fallback ), + } ) ), + useDispatch: () => ( { setOptInNotificationSeen: mockSetOptInNotificationSeen } ), +} ) ); + +// Bypass the DOM measuring/positioning; the controller only needs a style to render the card. +jest.mock( "../../../src/bulk-editor/components/tour/use-tour-anchor", () => ( { + useTourAnchor: () => ( { style: { top: "0px", left: "0px", width: "10px", height: "10px" } } ), +} ) ); + +/** + * Renders the tour and returns the select/deselect spies. + * + * @param {Object} [props] Prop overrides. + * @returns {{onSelectAll: jest.Mock, onDeselectAll: jest.Mock}} The spies. + */ +const renderTour = ( props = {} ) => { + const onSelectAll = jest.fn(); + const onDeselectAll = jest.fn(); + render( + + ); + return { onSelectAll, onDeselectAll }; +}; + +const clickNext = () => fireEvent.click( screen.getByRole( "button", { name: /Next/ } ) ); + +describe( "BulkEditorTour", () => { + beforeEach( () => { + jest.clearAllMocks(); + storeState.isSeen = false; + storeState.isAiEnabled = true; + } ); + + 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(); + + expect( screen.getByText( "1 / 4" ) ).toBeInTheDocument(); + expect( screen.getByRole( "heading", { name: "Select your content type" } ) ).toBeInTheDocument(); + } ); + + it( "advances and goes back through the steps", () => { + renderTour(); + + clickNext(); + expect( screen.getByText( "2 / 4" ) ).toBeInTheDocument(); + + fireEvent.click( screen.getByRole( "button", { name: "Back" } ) ); + expect( screen.getByText( "1 / 4" ) ).toBeInTheDocument(); + } ); + + it( "drops the generate step when AI is disabled, leaving three steps", () => { + storeState.isAiEnabled = false; + renderTour(); + + clickNext(); + clickNext(); + + expect( screen.getByText( "3 / 3" ) ).toBeInTheDocument(); + 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( "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( 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..4a42e8ddd14 --- /dev/null +++ b/packages/js/tests/bulk-editor/tour/tour-card.test.js @@ -0,0 +1,61 @@ +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(), +}; + +describe( "TourCard", () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( "renders the title, content and step progress", () => { + render( ); + + expect( screen.getByRole( "heading", { name: "Select your content type" } ) ).toBeInTheDocument(); + expect( screen.getByText( "This will refine the content." ) ).toBeInTheDocument(); + expect( screen.getByText( "1 / 4" ) ).toBeInTheDocument(); + } ); + + 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 ); + } ); +} ); 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..12bc0928c11 --- /dev/null +++ b/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js @@ -0,0 +1,74 @@ +import { renderHook } from "@testing-library/react"; +import { useTourAnchor } from "../../../src/bulk-editor/components/tour/use-tour-anchor"; + +let resizeObserverDisconnect; + +/** + * Adds a tour target to the DOM with a stubbed layout, since jsdom has none. + * + * @param {Object} rect The bounding rect the element should report. + * @returns {HTMLElement} The target element. + */ +const addTarget = ( rect = { top: 10, left: 20, width: 100, height: 30 } ) => { + document.body.innerHTML = "
"; + const target = document.querySelector( "[data-tour-id=\"x\"]" ); + // findVisibleTarget skips elements whose offsetParent is null; jsdom always reports null. + Object.defineProperty( target, "offsetParent", { get: () => document.body, configurable: true } ); + target.getBoundingClientRect = () => ( { ...rect, right: rect.left + rect.width, bottom: rect.top + rect.height } ); + return target; +}; + +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 style and does not highlight when inactive", () => { + const target = addTarget(); + + const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", false ) ); + + expect( result.current.style ).toBeNull(); + expect( target.classList.contains( "yst-feature-highlight" ) ).toBe( false ); + } ); + + it( "positions against the target and applies the spotlight when active", () => { + const target = addTarget( { top: 10, left: 20, width: 100, height: 30 } ); + + const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true ) ); + + expect( result.current.style ).toEqual( { top: "10px", left: "20px", width: "100px", height: "30px" } ); + expect( target.classList.contains( "yst-feature-highlight" ) ).toBe( true ); + expect( target.scrollIntoView ).toHaveBeenCalled(); + } ); + + it( "removes the spotlight and disconnects the observer on cleanup", () => { + const target = addTarget(); + + const { unmount } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true ) ); + unmount(); + + expect( target.classList.contains( "yst-feature-highlight" ) ).toBe( false ); + expect( resizeObserverDisconnect ).toHaveBeenCalled(); + } ); + + it( "returns no style when the target is absent", () => { + document.body.innerHTML = ""; + + const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"missing\"]", true ) ); + + expect( result.current.style ).toBeNull(); + } ); +} ); diff --git a/src/general/user-interface/general-page-integration.php b/src/general/user-interface/general-page-integration.php index 6d4a2fc6464..f462511ae6f 100644 --- a/src/general/user-interface/general-page-integration.php +++ b/src/general/user-interface/general-page-integration.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\Integrations\Integration_Interface; use Yoast\WP\SEO\Promotions\Application\Promotion_Manager; use Yoast\WP\SEO\Task_List\Application\Configuration\Task_List_Configuration; @@ -98,6 +99,13 @@ class General_Page_Integration implements Integration_Interface { */ private $options_helper; + /** + * Holds the user helper. + * + * @var User_Helper + */ + private $user_helper; + /** * Holds the WooCommerce conditional. * @@ -124,6 +132,7 @@ class General_Page_Integration implements Integration_Interface { * @param Promotion_Manager $promotion_manager The promotion manager. * @param Dashboard_Configuration $dashboard_configuration The dashboard configuration. * @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. @@ -138,6 +147,7 @@ public function __construct( Promotion_Manager $promotion_manager, Dashboard_Configuration $dashboard_configuration, Options_Helper $options_helper, + User_Helper $user_helper, WooCommerce_Conditional $woocommerce_conditional, WPSEO_Addon_Manager $addon_manager, Task_List_Configuration $task_list_configuration @@ -151,6 +161,7 @@ public function __construct( $this->promotion_manager = $promotion_manager; $this->dashboard_configuration = $dashboard_configuration; $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; @@ -270,6 +281,20 @@ private function get_script_data() { 'dismissedAlerts' => $this->alert_dismissal_action->all_dismissed(), 'dashboard' => $this->dashboard_configuration->get_configuration(), 'taskListConfiguration' => $this->task_list_configuration->get_configuration(), + 'optInNotificationSeen' => [ + 'bulk_editor_tour' => $this->is_bulk_editor_tour_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_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_bulk_editor_tour_opt_in_notification_seen', true ); + } } 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 f92ab0db3c3..704392e8a80 100644 --- a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php +++ b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php @@ -17,6 +17,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\Promotions\Application\Promotion_Manager; use Yoast\WP\SEO\Task_List\Application\Configuration\Task_List_Configuration; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -100,6 +101,13 @@ final class General_Page_Integration_Test extends TestCase { */ private $options_helper; + /** + * Holds the user helper mock. + * + * @var Mockery\MockInterface|User_Helper + */ + private $user_helper; + /** * Holds the WooCommerce conditional. * @@ -138,6 +146,7 @@ public function set_up() { $this->promotion_manager = Mockery::mock( Promotion_Manager::class ); $this->dashboard_configuration = Mockery::mock( Dashboard_Configuration::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 ); @@ -152,6 +161,7 @@ public function set_up() { $this->promotion_manager, $this->dashboard_configuration, $this->options_helper, + $this->user_helper, $this->woocommerce_conditional, $this->addon_manager, $this->task_list_configuration, @@ -178,6 +188,7 @@ public function test_construct() { $this->promotion_manager, $this->dashboard_configuration, $this->options_helper, + $this->user_helper, $this->woocommerce_conditional, $this->addon_manager, $this->task_list_configuration, @@ -343,6 +354,16 @@ public function test_enqueue_assets() { ->once() ->andReturn( false ); + $this->user_helper + ->expects( 'get_current_user_id' ) + ->once() + ->andReturn( 1 ); + $this->user_helper + ->expects( 'get_meta' ) + ->with( 1, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true ) + ->once() + ->andReturn( '' ); + $this->woocommerce_conditional ->expects( 'is_met' ) ->once() From e1e0131089afab028a0bdbe756017516e88ccb6e Mon Sep 17 00:00:00 2001 From: JordiPV Date: Fri, 24 Jul 2026 15:54:49 +0200 Subject: [PATCH 05/20] Add unit tests for BulkEditorTourNotification component --- .../bulk-editor-tour-notification.test.js | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/js/tests/general/components/bulk-editor-tour-notification.test.js 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 ); + } ); +} ); From d06ff517dfa9d141c2b900cdf69249a9df846004 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Fri, 24 Jul 2026 16:07:46 +0200 Subject: [PATCH 06/20] Updated the formatting --- .../user-interface/bulk-editor-integration.php | 14 +++++++------- .../user-interface/general-page-integration.php | 2 +- .../Enqueue_Assets_Test.php | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index e919db1dc43..2e3cd0d1163 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -221,24 +221,24 @@ public function enqueue_assets() { */ 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(), diff --git a/src/general/user-interface/general-page-integration.php b/src/general/user-interface/general-page-integration.php index f462511ae6f..7e22d436421 100644 --- a/src/general/user-interface/general-page-integration.php +++ b/src/general/user-interface/general-page-integration.php @@ -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 [ 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 22a6cf465e6..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,23 @@ 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, ], From 188cd8ebef18012de1e50ea76ec1afcc7c5af4ab Mon Sep 17 00:00:00 2001 From: JordiPV Date: Mon, 27 Jul 2026 15:09:05 +0200 Subject: [PATCH 07/20] Enhance bulk editor guided tour functionality and styling --- css/src/bulk-editor-page.css | 10 +- .../bulk-editor/components/bulk-action-bar.js | 46 ++--- .../components/bulk-editor-content.js | 162 +++++++++--------- .../bulk-editor/components/bulk-editor-nav.js | 38 +++- .../components/tour/bulk-editor-tour.js | 90 +++++++--- .../bulk-editor/components/tour/tour-card.js | 39 ++++- .../bulk-editor/components/tour/tour-steps.js | 5 +- .../components/tour/use-tour-anchor.js | 112 ++++++++++-- 8 files changed, 342 insertions(+), 160 deletions(-) diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index 4d249b6461a..ef9b9506b30 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -113,11 +113,7 @@ @apply yst-outline-none; } -/* The guided-tour spotlight: lifts the highlighted element above a viewport-filling shadow that dims the rest - * of the page, so the current step's target stays bright. The shadow roves as the class moves each step. */ -.yst-root .yst-feature-highlight { - position: relative; - z-index: 10; /* Above page content (z:auto); the tour card wrapper (z:100000) stays above the shadow. */ - box-shadow: 0 0 0 100vmax rgb(100 116 139 / 0.75); /* Slate-500/75 spotlight dim. */ - border-radius: 0.5rem; /* Soften the bright cut-out's corners. */ +/* The in-app guided-tour. It is decorative only so prevents interaction */ +.yst-root .yst-tour-spotlight { + pointer-events: none; } diff --git a/packages/js/src/bulk-editor/components/bulk-action-bar.js b/packages/js/src/bulk-editor/components/bulk-action-bar.js index c7d76f8626a..a6d380b322c 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -79,22 +79,24 @@ export const SelectionToolbar = ( { idSuffix = "", isAllSelected, isIndeterminat }, [ isIndeterminate ] ); return ( -
- - +
+
+ + +
{ selectedCount > 0 && ( { sprintf( @@ -249,11 +251,13 @@ const BulkActionsNotices = ( { const BulkActionsBand = ( { isPremium, isAiEnabled, isActive, selectedIds, activeFieldSet, contentType, hasUnsavedEdits, editCount, onApplyAll, onDiscardAll, isApplyingAll, } ) => ( -
- { ! isPremium && isAiEnabled && } - { isActive && ( - - ) } +
+
+ { ! isPremium && isAiEnabled && } + { isActive && ( + + ) } +
{ isActive && hasUnsavedEdits && ( ) } 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 2aec41ec3a4..4d325df4bb7 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-content.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-content.js @@ -145,88 +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 9e745acf261..de37fd00017 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,19 @@ export const BulkEditorNavMenu = ( { } ) => { const hiddenCount = contentTypes.length - visibleLimit; + // The in-app guided-tour covers only the leading built-in content types (Posts, Pages), + // never commerce or custom types. + const highlightEndIndex = ( () => { + let end = -1; + for ( let index = 0; index < contentTypes.length && index < 2; index++ ) { + if ( ! BUILT_IN_CONTENT_TYPES.includes( contentTypes[ index ].id ) ) { + break; + } + end = index; + } + return end; + } )(); + const showMoreLabel = sprintf( /* translators: %d expands to the number of additional content types. */ _n( "Show %d more", "Show %d more", hiddenCount, "wordpress-seo" ), @@ -132,8 +150,14 @@ export const BulkEditorNavMenu = ( { showMoreLabel={ showMoreLabel } showLessLabel={ __( "Show less", "wordpress-seo" ) } > - { contentTypes.map( ( contentType ) => ( - + { contentTypes.map( ( contentType, index ) => ( + ) ) }
diff --git a/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js b/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js index 3e694c6eb66..d737c954e12 100644 --- a/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js +++ b/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js @@ -5,6 +5,9 @@ import { TourCard } from "./tour-card"; import { getTourSteps } from "./tour-steps"; import { useTourAnchor } from "./use-tour-anchor"; +// The id linking the overlay's rectangle to its cut-out mask. Only one tour is active at a time, so a constant is safe. +const SPOTLIGHT_MASK_ID = "yoast-bulk-editor-tour-spotlight-mask"; + /** * The first-time guided tour for the bulk editor. * @@ -52,7 +55,10 @@ export const BulkEditorTour = ( { onSelectAll, onDeselectAll, hasSelection } ) = } }, [ isActive, step, hasSelection, onSelectAll ] ); - const { style } = useTourAnchor( `[data-tour-id="${ step.tourId }"]`, isActive ); + const { spotlight } = useTourAnchor( `[data-tour-id="${ step.tourId }"]`, isActive, { + endSelector: step.highlightEndSelector, + perChild: step.highlightChildren, + } ); const finish = useCallback( () => { if ( didAutoSelect.current ) { @@ -64,35 +70,71 @@ export const BulkEditorTour = ( { onSelectAll, onDeselectAll, hasSelection } ) = }, [ onDeselectAll, setOptInNotificationSeen ] ); const onNext = useCallback( () => { - setStepIndex( ( index ) => { - if ( index >= steps.length - 1 ) { - finish(); - return index; - } - return index + 1; - } ); - }, [ steps.length, finish ] ); + // 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 ( ! isActive || ! style ) { + if ( ! isActive || ! spotlight ) { return null; } + const { rects, bounds, viewport } = spotlight; + return ( -
- 0 ? onBack : null } - onSkip={ finish } - /> -
+ <> +
+ +
+ 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 index 36cb1c3a37e..a0c0f36c9c6 100644 --- a/packages/js/src/bulk-editor/components/tour/tour-card.js +++ b/packages/js/src/bulk-editor/components/tour/tour-card.js @@ -4,6 +4,27 @@ 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. * @@ -46,9 +67,20 @@ export const TourCard = ( { return () => clearTimeout( timeout ); }, [ currentStep ] ); - // The close button, backdrop and Escape all resolve to hiding the popover, which ends the tour. + // 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 <>
- +
@@ -76,7 +109,7 @@ export const TourCard = ( { { content }
- + { sprintf( /* translators: %1$s is the current step number, %2$s is the total number of steps. */ __( "%1$s / %2$s", "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 index dd5eb7e9b01..225d20e549d 100644 --- a/packages/js/src/bulk-editor/components/tour/tour-steps.js +++ b/packages/js/src/bulk-editor/components/tour/tour-steps.js @@ -12,6 +12,7 @@ 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" ), @@ -26,6 +27,7 @@ export const getTourSteps = () => [ { 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" ), @@ -33,7 +35,8 @@ export const getTourSteps = () => [ { id: "bulk-editor-tour-generate", tourId: "generate-actions", - position: "bottom-right", + highlightChildren: true, + 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 index be14f0d08bc..57410d70cae 100644 --- a/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js +++ b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js @@ -5,6 +5,29 @@ import { useState, useEffect } from "@wordpress/element"; const TARGET_WAIT_MS = 2000; const TARGET_POLL_MS = 100; +// Space (px) around a single-region spotlight. The right side gets extra room for the gap to the popover. +const SPOTLIGHT_PADDING = { top: 8, right: 24, bottom: 8, left: 8 }; +// 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. * @@ -16,15 +39,27 @@ const findVisibleTarget = ( selector ) => [ ...document.querySelectorAll( selector ) ].find( ( element ) => element.offsetParent !== null ) ?? null; /** - * Positions the tour card against a target element and moves the spotlight highlight onto it. + * Whether an element exsists and is visible. + * + * @param {HTMLElement} element The element. + * + * @returns {boolean} Whether it is visible. + */ +const isVisible = ( element ) => element.offsetParent !== null && element.getBoundingClientRect().width > 0; + +/** + * 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 and highlighted. + * @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. * - * @returns {{style: Object|null}} The wrapper's absolute-position style, or null while no target is found. + * @returns {{spotlight: {rects: Array, bounds: Object, viewport: Object}|null}} The spotlight geometry, or null. */ -export const useTourAnchor = ( targetSelector, isActive ) => { - const [ style, setStyle ] = useState( null ); +export const useTourAnchor = ( targetSelector, isActive, { endSelector = null, perChild = false } = {} ) => { + const [ spotlight, setSpotlight ] = useState( null ); useEffect( () => { if ( ! isActive ) { @@ -35,16 +70,60 @@ export const useTourAnchor = ( targetSelector, isActive ) => { let cleanup = null; const attach = ( element ) => { - element.classList.add( "yst-feature-highlight" ); - element.scrollIntoView( { behavior: "smooth", block: "center" } ); + // Instant, minimal scroll: only nudge the target into view if it is off-screen. + element.scrollIntoView( { block: "nearest" } ); const updatePosition = () => { - const rect = element.getBoundingClientRect(); - setStyle( { - top: `${ rect.top }px`, - left: `${ rect.left }px`, - width: `${ rect.width }px`, - height: `${ rect.height }px`, + let rects; + if ( perChild ) { + rects = [ ...element.children ].filter( isVisible ).map( ( child ) => { + const rect = child.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + rx: cornerRadius( child ), + }; + } ); + } else { + const rect = element.getBoundingClientRect(); + // Clamp to the end element's bottom when present, so the rest of the target stays dimmed. + 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; + rects = [ { + top, + left: rect.left - SPOTLIGHT_PADDING.left, + width: rect.width + SPOTLIGHT_PADDING.left + SPOTLIGHT_PADDING.right, + height: bottom - top, + rx: SINGLE_REGION_RADIUS, + } ]; + } + + // 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 ) { + setSpotlight( null ); + return; + } + + 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 ) ); + + setSpotlight( { + rects, + bounds: { + top: `${ minTop }px`, + left: `${ minLeft }px`, + width: `${ maxRight - minLeft }px`, + height: `${ maxBottom - minTop }px`, + }, + viewport: { width: window.innerWidth, height: window.innerHeight }, } ); }; updatePosition(); @@ -55,7 +134,6 @@ export const useTourAnchor = ( targetSelector, isActive ) => { observer.observe( element ); cleanup = () => { - element.classList.remove( "yst-feature-highlight" ); window.removeEventListener( "scroll", updatePosition, true ); window.removeEventListener( "resize", updatePosition ); observer.disconnect(); @@ -84,9 +162,9 @@ export const useTourAnchor = ( targetSelector, isActive ) => { if ( cleanup ) { cleanup(); } - setStyle( null ); + setSpotlight( null ); }; - }, [ targetSelector, isActive ] ); + }, [ targetSelector, isActive, endSelector, perChild ] ); - return { style }; + return { spotlight }; }; From 55884c657071415374dc7ea3c338c8123808fe3a Mon Sep 17 00:00:00 2001 From: JordiPV Date: Mon, 27 Jul 2026 15:09:26 +0200 Subject: [PATCH 08/20] Refactor tour tests to enhance spotlight functionality and layout --- .../bulk-editor/tour/bulk-editor-tour.test.js | 9 +- .../tests/bulk-editor/tour/tour-card.test.js | 8 ++ .../bulk-editor/tour/use-tour-anchor.test.js | 99 +++++++++++++++---- 3 files changed, 95 insertions(+), 21 deletions(-) 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 index ad3df3389aa..7a8022471c2 100644 --- a/packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js +++ b/packages/js/tests/bulk-editor/tour/bulk-editor-tour.test.js @@ -14,9 +14,14 @@ jest.mock( "@wordpress/data", () => ( { useDispatch: () => ( { setOptInNotificationSeen: mockSetOptInNotificationSeen } ), } ) ); -// Bypass the DOM measuring/positioning; the controller only needs a style to render the card. jest.mock( "../../../src/bulk-editor/components/tour/use-tour-anchor", () => ( { - useTourAnchor: () => ( { style: { top: "0px", left: "0px", width: "10px", height: "10px" } } ), + useTourAnchor: () => ( { + spotlight: { + rects: [ { top: 0, left: 0, width: 10, height: 10 } ], + bounds: { top: "0px", left: "0px", width: "10px", height: "10px" }, + viewport: { width: 1024, height: 768 }, + }, + } ), } ) ); /** diff --git a/packages/js/tests/bulk-editor/tour/tour-card.test.js b/packages/js/tests/bulk-editor/tour/tour-card.test.js index 4a42e8ddd14..a424de1dc05 100644 --- a/packages/js/tests/bulk-editor/tour/tour-card.test.js +++ b/packages/js/tests/bulk-editor/tour/tour-card.test.js @@ -58,4 +58,12 @@ describe( "TourCard", () => { 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/use-tour-anchor.test.js b/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js index 12bc0928c11..48cfe228951 100644 --- a/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js +++ b/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js @@ -4,18 +4,44 @@ import { useTourAnchor } from "../../../src/bulk-editor/components/tour/use-tour let resizeObserverDisconnect; /** - * Adds a tour target to the DOM with a stubbed layout, since jsdom has none. + * 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. * - * @param {Object} rect The bounding rect the element should report. * @returns {HTMLElement} The target element. */ const addTarget = ( rect = { top: 10, left: 20, width: 100, height: 30 } ) => { document.body.innerHTML = "
"; - const target = document.querySelector( "[data-tour-id=\"x\"]" ); - // findVisibleTarget skips elements whose offsetParent is null; jsdom always reports null. - Object.defineProperty( target, "offsetParent", { get: () => document.body, configurable: true } ); - target.getBoundingClientRect = () => ( { ...rect, right: rect.left + rect.width, bottom: rect.top + rect.height } ); - return target; + 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", () => { @@ -35,40 +61,75 @@ describe( "useTourAnchor", () => { document.body.innerHTML = ""; } ); - it( "returns no style and does not highlight when inactive", () => { - const target = addTarget(); + it( "returns no spotlight when inactive", () => { + addTarget(); const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", false ) ); - expect( result.current.style ).toBeNull(); - expect( target.classList.contains( "yst-feature-highlight" ) ).toBe( false ); + expect( result.current.spotlight ).toBeNull(); } ); - it( "positions against the target and applies the spotlight when active", () => { + 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 ) ); - expect( result.current.style ).toEqual( { top: "10px", left: "20px", width: "100px", height: "30px" } ); - expect( target.classList.contains( "yst-feature-highlight" ) ).toBe( true ); + // The target rect grows by the spotlight padding: 8 all round plus an extra 24 on the right. + expect( result.current.spotlight.rects ).toEqual( [ { top: 2, left: 12, width: 132, height: 46, rx: 8 } ] ); + expect( result.current.spotlight.bounds ).toEqual( { top: "2px", left: "12px", width: "132px", height: "46px" } ); + expect( result.current.spotlight.viewport ).toEqual( { width: window.innerWidth, height: window.innerHeight } ); expect( target.scrollIntoView ).toHaveBeenCalled(); } ); - it( "removes the spotlight and disconnects the observer on cleanup", () => { - const target = addTarget(); + 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 (2) to the end element's bottom (60), not the target's own 90-tall rect. + expect( result.current.spotlight.rects ).toEqual( [ { top: 2, left: 12, width: 132, height: 58, 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( "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( target.classList.contains( "yst-feature-highlight" ) ).toBe( false ); expect( resizeObserverDisconnect ).toHaveBeenCalled(); } ); - it( "returns no style when the target is absent", () => { + it( "returns no spotlight when the target is absent", () => { document.body.innerHTML = ""; const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"missing\"]", true ) ); - expect( result.current.style ).toBeNull(); + expect( result.current.spotlight ).toBeNull(); } ); } ); From 74c747233cae9a8f52cee5f7edb41601edd97b10 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Mon, 27 Jul 2026 15:26:18 +0200 Subject: [PATCH 09/20] chore(bulk-editor): drop unrelated changes reintroduced on the branch Removes the AI Brand Insights modals/classes/tests and image that #23439 deleted from trunk, plus stray wordpress-urls (class-wpseo-utils) and yarn.lock edits, so this PR only contains the guided-tour work. Co-Authored-By: Claude Opus 4.8 --- apps/content-analysis-webworker/yarn.lock | 12 +- images/ai-brand-insights-pre-launch.jpg | Bin 14370 -> 0 bytes inc/class-wpseo-utils.php | 14 ++ .../modals/ai-brand-insights-free-trial.js | 113 ------------ .../modals/ai-brand-insights-post-launch.js | 111 ------------ .../modals/ai-brand-insights-pre-launch.js | 110 ------------ packages/js/src/introductions/initialize.js | 4 - .../ai-brand-insights-free-trial.php | 72 -------- .../ai-brand-insights-post-launch.php | 72 -------- .../AI_Brand_Insights_Free_Trial_Test.php | 165 ------------------ .../AI_Brand_Insights_Post_Launch_Test.php | 165 ------------------ tests/WP/Inc/Utils_Test.php | 36 ++-- yarn.lock | 14 +- 13 files changed, 55 insertions(+), 833 deletions(-) delete mode 100644 images/ai-brand-insights-pre-launch.jpg delete mode 100644 packages/js/src/introductions/components/modals/ai-brand-insights-free-trial.js delete mode 100644 packages/js/src/introductions/components/modals/ai-brand-insights-post-launch.js delete mode 100644 packages/js/src/introductions/components/modals/ai-brand-insights-pre-launch.js delete mode 100644 src/introductions/application/ai-brand-insights-free-trial.php delete mode 100644 src/introductions/application/ai-brand-insights-post-launch.php delete mode 100644 tests/Unit/Introductions/Application/AI_Brand_Insights_Free_Trial_Test.php delete mode 100644 tests/Unit/Introductions/Application/AI_Brand_Insights_Post_Launch_Test.php diff --git a/apps/content-analysis-webworker/yarn.lock b/apps/content-analysis-webworker/yarn.lock index d2d946dd02e..afe5088670b 100644 --- a/apps/content-analysis-webworker/yarn.lock +++ b/apps/content-analysis-webworker/yarn.lock @@ -1173,9 +1173,9 @@ fast-deep-equal@^3.1.3: integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== fast-uri@^3.0.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" - integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== + version "3.1.4" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.4.tgz#3b3daf9ce68f41f956df0b505132c0cfce9ec7af" + integrity sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw== faye-websocket@^0.11.3: version "0.11.4" @@ -2440,3 +2440,9 @@ xtend@~4.0.1: parse5 "^8.0.0" tiny-segmenter "^0.2.0" tokenizer2 "^2.0.1" + zod "^3.25.76" + +zod@^3.25.76: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== diff --git a/images/ai-brand-insights-pre-launch.jpg b/images/ai-brand-insights-pre-launch.jpg deleted file mode 100644 index 30f470ad510713f1f7aadbc809a17a26ff11c431..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14370 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 I(Z$IB0qCK1lK=n! diff --git a/inc/class-wpseo-utils.php b/inc/class-wpseo-utils.php index 86473457d29..a98984dbe9a 100644 --- a/inc/class-wpseo-utils.php +++ b/inc/class-wpseo-utils.php @@ -183,6 +183,20 @@ public static function sanitize_text_field( $value ) { */ public static function sanitize_url( $value, $allowed_protocols = [ 'http', 'https' ] ) { + // Percent-encode non-ASCII bytes in the path/query/fragment before parsing, so wp_parse_url() + // does not corrupt multibyte UTF-8 characters. The authority (userinfo + host) is left untouched + // to avoid double-encoding it during the sanitization below. + if ( preg_match( '/[\x80-\xff]/', $value ) === 1 ) { + preg_match( '`^((?:[a-z][a-z0-9+.\-]*:)?//[^/?#]*)?(.*)$`is', $value, $split ); + $value = $split[1] . preg_replace_callback( + '/[\x80-\xff]/', + static function ( $bytes ) { + return rawurlencode( $bytes[0] ); + }, + $split[2], + ); + } + $url = ''; $parts = wp_parse_url( $value ); diff --git a/packages/js/src/introductions/components/modals/ai-brand-insights-free-trial.js b/packages/js/src/introductions/components/modals/ai-brand-insights-free-trial.js deleted file mode 100644 index 1c84b155000..00000000000 --- a/packages/js/src/introductions/components/modals/ai-brand-insights-free-trial.js +++ /dev/null @@ -1,113 +0,0 @@ -import { useSelect } from "@wordpress/data"; -import { createInterpolateElement, useMemo } from "@wordpress/element"; -import { __ } from "@wordpress/i18n"; -import ExternalLinkIcon from "@heroicons/react/outline/ExternalLinkIcon"; -import { Button, Modal as UiModal, useSvgAria, useModalContext } from "@yoast/ui-library"; -import { STORE_NAME_INTRODUCTIONS } from "../../constants"; -import { Modal } from "../modal"; - -/** - * @param {Object} thumbnail The thumbnail: img props. - * @param {string} buttonLink The button link. - * @returns {JSX.Element} The element. - */ -const AiBrandInsightsFreeTrialContent = ( { - thumbnail, - buttonLink, -} ) => { - const { onClose, initialFocus } = useModalContext(); - const svgAriaProps = useSvgAria(); - - return ( - <> -
- { -
- - - { "Yoast AI Brand Insights" } - -
-
-
-
- - { - __( "Your first brand analysis is free!", "wordpress-seo" ) - } - -
-

- { createInterpolateElement( - __( - "As a Yoast customer, you can run your first brand analysis for free. See how your brand shows up in AI responses.", - "wordpress-seo" - ), - { - strong: , - } - ) } -

-
-
-
- -
- -
- - ); -}; - -/** - * @returns {JSX.Element} The element. - */ -export const AiBrandInsightsFreeTrial = () => { - const imageLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectImageLink( "ai-brand-insights-pre-launch.jpg" ), [] ); - const buttonLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ) - .selectLink( "https://yoa.st/aibi-introduction-free-trial" ), [] ); - const thumbnail = useMemo( () => ( { - src: imageLink, - width: "432", - height: "243", - } ), [ imageLink ] ); - - return ( - - - - ); -}; diff --git a/packages/js/src/introductions/components/modals/ai-brand-insights-post-launch.js b/packages/js/src/introductions/components/modals/ai-brand-insights-post-launch.js deleted file mode 100644 index 70c99a3684b..00000000000 --- a/packages/js/src/introductions/components/modals/ai-brand-insights-post-launch.js +++ /dev/null @@ -1,111 +0,0 @@ -import { useSelect } from "@wordpress/data"; -import { useMemo } from "@wordpress/element"; -import { __, sprintf } from "@wordpress/i18n"; -import { Button, useModalContext } from "@yoast/ui-library"; -import { STORE_NAME_INTRODUCTIONS } from "../../constants"; -import { Modal } from "../modal"; - -/** - * @param {Object} thumbnail The thumbnail: img props. - * @param {string} buttonLink The button link. - * @param {string} buttonLabel The button label. - * @param {string} productName The product name. - * @param {string} description The description for the introduction - * @param {string} ctbId The click to buy to register for this upsell instance. - * @returns {JSX.Element} The element. - */ -const AiBrandInsightsPostLaunchContent = ( { - thumbnail, - buttonLink, - buttonLabel = __( "Discover Brand Insights now", "wordpress-seo" ), - productName = sprintf( - /* translators: %1$s expands to Yoast SEO AI+. */ - __( "New - Upgrade to %1$s", "wordpress-seo" ), - "Yoast SEO AI+" - ), -} ) => { - const { onClose, initialFocus } = useModalContext(); - - const description = __( "Track visibility, control perception, and stay ahead - tools to manage your AI presence are now live!", "wordpress-seo" ); - - return ( - <> -
- { -
- - - { productName } - -
-
-
-
-

- { - __( "How does your brand show up in AI responses?", "wordpress-seo" ) - } -

-
-

{ description }

-
-
-
- -
- -
- - ); -}; - -/** - * @returns {JSX.Element} The element. - */ -export const AiBrandInsightsPostLaunch = () => { - const imageLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectImageLink( "ai-brand-insights-pre-launch.jpg" ), [] ); - const joinWishlistLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ) - .selectLink( "https://yoa.st/ai-brand-insights-introduction-post-launch/" ), [] ); - const thumbnail = useMemo( () => ( { - src: imageLink, - width: "432", - height: "243", - } ), [ imageLink ] ); - - return ( - - - - ); -}; diff --git a/packages/js/src/introductions/components/modals/ai-brand-insights-pre-launch.js b/packages/js/src/introductions/components/modals/ai-brand-insights-pre-launch.js deleted file mode 100644 index 934dbfcc998..00000000000 --- a/packages/js/src/introductions/components/modals/ai-brand-insights-pre-launch.js +++ /dev/null @@ -1,110 +0,0 @@ -import { useSelect } from "@wordpress/data"; -import { useMemo } from "@wordpress/element"; -import { __, sprintf } from "@wordpress/i18n"; -import { Button, useModalContext } from "@yoast/ui-library"; -import { STORE_NAME_INTRODUCTIONS } from "../../constants"; -import { Modal } from "../modal"; - -/** - * @param {Object} thumbnail The thumbnail: img props. - * @param {string} buttonLink The button link. - * @param {string} buttonLabel The button label. - * @param {string} productName The product name. - * @param {string} description The description for the introduction - * @param {string} ctbId The click to buy to register for this upsell instance. - * @returns {JSX.Element} The element. - */ -const AiBrandInsightsPreLaunchContent = ( { - thumbnail, - buttonLink, - buttonLabel = __( "Join the waitlist", "wordpress-seo" ), - productName = sprintf( - /* translators: %1$s expands to Yoast AI brand insights. */ - __( "Introducing %1$s", "wordpress-seo" ), - "Yoast AI brand insights" - ), - description = __( "Track visibility, control perception, and stay ahead - tools to manage your AI presence are coming soon!", "wordpress-seo" ), -} ) => { - const { onClose, initialFocus } = useModalContext(); - - return ( - <> -
- { -
- - - { productName } - -
-
-
-
-

- { - __( "How does your brand show up in AI responses?", "wordpress-seo" ) - } -

-
-

{ description }

-
-
-
- -
- -
- - ); -}; - -/** - * @returns {JSX.Element} The element. - */ -export const AiBrandInsightsPreLaunch = () => { - const imageLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectImageLink( "ai-brand-insights-pre-launch.jpg" ), [] ); - const joinWishlistLink = useSelect( select => select( STORE_NAME_INTRODUCTIONS ).selectLink( "https://yoa.st/ai-brand-insights-introduction-pre-launch/" ), [] ); - - const thumbnail = useMemo( () => ( { - src: imageLink, - width: "432", - height: "243", - } ), [ imageLink ] ); - - return ( - - - - ); -}; diff --git a/packages/js/src/introductions/initialize.js b/packages/js/src/introductions/initialize.js index d5d421d8583..658274be06d 100644 --- a/packages/js/src/introductions/initialize.js +++ b/packages/js/src/introductions/initialize.js @@ -7,8 +7,6 @@ import { Root } from "@yoast/ui-library"; import { get, isEmpty, find } from "lodash"; import { LINK_PARAMS_NAME, PLUGIN_URL_NAME, WISTIA_EMBED_PERMISSION_NAME } from "../shared-admin/store"; import { Introduction, IntroductionProvider } from "./components"; -import { AiBrandInsightsFreeTrial } from "./components/modals/ai-brand-insights-free-trial"; -import { AiBrandInsightsPostLaunch } from "./components/modals/ai-brand-insights-post-launch"; import { BlackFridayAnnouncement } from "./components/modals/black-friday-announcement"; import { DelayedPremiumUpsell } from "./components/modals/delayed-premium-upsell"; import { SchemaAggregatorAnnouncement } from "./components/modals/schema-aggregator-announcement"; @@ -24,8 +22,6 @@ domReady( () => { } const initialComponents = { - "ai-brand-insights-free-trial": AiBrandInsightsFreeTrial, - "ai-brand-insights-post-launch": AiBrandInsightsPostLaunch, "black-friday-announcement": BlackFridayAnnouncement, "delayed-premium-upsell": DelayedPremiumUpsell, "schema-aggregator-announcement": SchemaAggregatorAnnouncement, diff --git a/src/introductions/application/ai-brand-insights-free-trial.php b/src/introductions/application/ai-brand-insights-free-trial.php deleted file mode 100644 index c5a500bb6ff..00000000000 --- a/src/introductions/application/ai-brand-insights-free-trial.php +++ /dev/null @@ -1,72 +0,0 @@ -current_page_helper = $current_page_helper; - $this->product_helper = $product_helper; - } - - /** - * Returns the ID. - * - * @return string The ID. - */ - public function get_id() { - return self::ID; - } - - /** - * Returns the requested pagination priority. Lower means earlier. - * - * @return int The priority. - */ - public function get_priority() { - return 20; - } - - /** - * Returns whether this introduction should show. - * - * @return bool Whether this introduction should show. - */ - public function should_show() { - return $this->current_page_helper->is_yoast_seo_page() && $this->product_helper->is_premium(); - } -} diff --git a/src/introductions/application/ai-brand-insights-post-launch.php b/src/introductions/application/ai-brand-insights-post-launch.php deleted file mode 100644 index 667048c13e6..00000000000 --- a/src/introductions/application/ai-brand-insights-post-launch.php +++ /dev/null @@ -1,72 +0,0 @@ -current_page_helper = $current_page_helper; - $this->product_helper = $product_helper; - } - - /** - * Returns the ID. - * - * @return string The ID. - */ - public function get_id() { - return self::ID; - } - - /** - * Returns the requested pagination priority. Lower means earlier. - * - * @return int The priority. - */ - public function get_priority() { - return 20; - } - - /** - * Returns whether this introduction should show. - * - * @return bool Whether this introduction should show. - */ - public function should_show() { - return $this->current_page_helper->is_yoast_seo_page() && ! $this->product_helper->is_premium(); - } -} diff --git a/tests/Unit/Introductions/Application/AI_Brand_Insights_Free_Trial_Test.php b/tests/Unit/Introductions/Application/AI_Brand_Insights_Free_Trial_Test.php deleted file mode 100644 index bbf3f3d7718..00000000000 --- a/tests/Unit/Introductions/Application/AI_Brand_Insights_Free_Trial_Test.php +++ /dev/null @@ -1,165 +0,0 @@ -current_page_helper = Mockery::mock( Current_Page_Helper::class ); - $this->product_helper = Mockery::mock( Product_Helper::class ); - - $this->instance = new AI_Brand_Insights_Free_Trial( - $this->current_page_helper, - $this->product_helper, - ); - } - - /** - * Tests if the needed attributes are set correctly. - * - * @covers ::__construct - * - * @return void - */ - public function test_constructor() { - $this->assertInstanceOf( - Current_Page_Helper::class, - $this->getPropertyValue( $this->instance, 'current_page_helper' ), - ); - $this->assertInstanceOf( - Product_Helper::class, - $this->getPropertyValue( $this->instance, 'product_helper' ), - ); - } - - /** - * Tests getting the ID. - * - * @covers ::get_id - * - * @return void - */ - public function test_get_name() { - $this->assertSame( 'ai-brand-insights-free-trial', $this->instance->get_id() ); - } - - /** - * Tests getting the priority. - * - * @covers ::get_priority - * - * @return void - */ - public function test_get_priority() { - $this->assertSame( 20, $this->instance->get_priority() ); - } - - /** - * Tests the conditional `should_show`. - * - * @covers ::should_show - * - * @dataProvider should_show_data - * - * @param bool $is_yoast_seo_page Whether on a Yoast SEO page. - * @param bool $is_premium Whether Premium is installed. - * @param int $is_premium_times How many times the `is_premium` method is expected to be called. - * @param bool $expected The expected result. - * - * @return void - */ - public function test_should_show( - $is_yoast_seo_page, - $is_premium, - $is_premium_times, - $expected - ) { - $this->current_page_helper->expects( 'is_yoast_seo_page' ) - ->once() - ->withNoArgs() - ->andReturn( $is_yoast_seo_page ); - $this->product_helper->expects( 'is_premium' ) - ->times( $is_premium_times ) - ->withNoArgs() - ->andReturn( $is_premium ); - - $this->assertSame( $expected, $this->instance->should_show() ); - } - - /** - * Provides the data for `test_should_show`. - * - * @return array> - */ - public static function should_show_data() { - return [ - 'on a Yoast admin page, with Premium enabled' => [ - 'is_yoast_seo_page' => true, - 'is_premium' => true, - 'is_premium_times' => 1, - 'expected' => true, - ], - 'on a Yoast admin page, with Premium disabled' => [ - 'is_yoast_seo_page' => true, - 'is_premium' => false, - 'is_premium_times' => 1, - 'expected' => false, - ], - 'not on a Yoast admin page, with Premium enabled' => [ - 'is_yoast_seo_page' => false, - 'is_premium' => true, - 'is_premium_times' => 0, - 'expected' => false, - ], - 'not on a Yoast admin page, with Premium disabled' => [ - 'is_yoast_seo_page' => false, - 'is_premium' => false, - 'is_premium_times' => 0, - 'expected' => false, - ], - ]; - } -} diff --git a/tests/Unit/Introductions/Application/AI_Brand_Insights_Post_Launch_Test.php b/tests/Unit/Introductions/Application/AI_Brand_Insights_Post_Launch_Test.php deleted file mode 100644 index 5be57f920fe..00000000000 --- a/tests/Unit/Introductions/Application/AI_Brand_Insights_Post_Launch_Test.php +++ /dev/null @@ -1,165 +0,0 @@ -current_page_helper = Mockery::mock( Current_Page_Helper::class ); - $this->product_helper = Mockery::mock( Product_Helper::class ); - - $this->instance = new AI_Brand_Insights_Post_Launch( - $this->current_page_helper, - $this->product_helper, - ); - } - - /** - * Tests if the needed attributes are set correctly. - * - * @covers ::__construct - * - * @return void - */ - public function test_constructor() { - $this->assertInstanceOf( - Current_Page_Helper::class, - $this->getPropertyValue( $this->instance, 'current_page_helper' ), - ); - $this->assertInstanceOf( - Product_Helper::class, - $this->getPropertyValue( $this->instance, 'product_helper' ), - ); - } - - /** - * Tests getting the ID. - * - * @covers ::get_id - * - * @return void - */ - public function test_get_name() { - $this->assertSame( 'ai-brand-insights-post-launch', $this->instance->get_id() ); - } - - /** - * Tests getting the priority. - * - * @covers ::get_priority - * - * @return void - */ - public function test_get_priority() { - $this->assertSame( 20, $this->instance->get_priority() ); - } - - /** - * Tests the conditional `should_show`. - * - * @covers ::should_show - * - * @dataProvider should_show_data - * - * @param bool $is_yoast_seo_page Whether on a Yoast SEO page. - * @param bool $is_premium Whether Premium is installed. - * @param int $is_premium_times How many times the `is_premium` method is expected to be called. - * @param bool $expected The expected result. - * - * @return void - */ - public function test_should_show( - $is_yoast_seo_page, - $is_premium, - $is_premium_times, - $expected - ) { - $this->current_page_helper->expects( 'is_yoast_seo_page' ) - ->once() - ->withNoArgs() - ->andReturn( $is_yoast_seo_page ); - $this->product_helper->expects( 'is_premium' ) - ->times( $is_premium_times ) - ->withNoArgs() - ->andReturn( $is_premium ); - - $this->assertSame( $expected, $this->instance->should_show() ); - } - - /** - * Provides the data for `test_should_show`. - * - * @return array> - */ - public static function should_show_data() { - return [ - 'on a Yoast admin page, with Premium disabled' => [ - 'is_yoast_seo_page' => true, - 'is_premium' => false, - 'is_premium_times' => 1, - 'expected' => true, - ], - 'on a Yoast admin page, with Premium enabled' => [ - 'is_yoast_seo_page' => true, - 'is_premium' => true, - 'is_premium_times' => 1, - 'expected' => false, - ], - 'not on a Yoast admin page, with Premium disabled' => [ - 'is_yoast_seo_page' => false, - 'is_premium' => false, - 'is_premium_times' => 0, - 'expected' => false, - ], - 'not on a Yoast admin page, with Premium enabled' => [ - 'is_yoast_seo_page' => false, - 'is_premium' => true, - 'is_premium_times' => 0, - 'expected' => false, - ], - ]; - } -} diff --git a/tests/WP/Inc/Utils_Test.php b/tests/WP/Inc/Utils_Test.php index 658076f5a66..baa16436b4e 100644 --- a/tests/WP/Inc/Utils_Test.php +++ b/tests/WP/Inc/Utils_Test.php @@ -184,12 +184,12 @@ public function test_sanitize_url( $expected, $url_to_sanitize ) { public static function sanitize_url_provider() { return [ // Related issue: https://github.com/Yoast/wordpress-seo/issues/17099. - 'with_at_sign_in_url_path' => [ + 'with_at_sign_in_url_path' => [ 'expected' => 'https://example.org/test1/@test2', 'url_to_sanitize' => 'https://example.org/test1/@test2', ], // Related issue: https://github.com/Yoast/wordpress-seo/issues/14476. - 'with_encoded_url' => [ + 'with_encoded_url' => [ 'expected' => 'https://example.com/%da%af%d8%b1%d9%88%d9%87-%d8%aa%d9%84%da%af%d8%b1%d8%a7%d9%85-%d8%b3%d8%a6%d9%88/', 'url_to_sanitize' => 'https://example.com/%da%af%d8%b1%d9%88%d9%87-%d8%aa%d9%84%da%af%d8%b1%d8%a7%d9%85-%d8%b3%d8%a6%d9%88/', ], @@ -198,42 +198,56 @@ public static function sanitize_url_provider() { 'url_to_sanitize' => 'https://example.com/گروه-تلگرام-سئو', ], // Related issue: https://github.com/Yoast/wordpress-seo/issues/7664. - 'invalid_url' => [ + 'invalid_url' => [ 'expected' => '', 'url_to_sanitize' => 'WordPress', ], - 'only_absolute_path' => [ + 'only_absolute_path' => [ 'expected' => '/images/user-defined.png', 'url_to_sanitize' => '/images/user-defined.png', ], - 'with_non_encoded_url' => [ + 'with_non_encoded_url' => [ 'expected' => 'https://example.org/this-is-a-page', 'url_to_sanitize' => 'https://example.org/this-is-a-page', ], - 'with_html_in_url' => [ + 'with_html_in_url' => [ 'expected' => 'https://example.org/this-is-a-page', 'url_to_sanitize' => 'https://example.org/this-is-a-page', ], - 'with_all_components_in_url' => [ + 'with_all_components_in_url' => [ 'expected' => 'http://user:pass@example.com:8080/subdir/test1?mod%c3%a8le=num%c3%a9rique#compl%c3%a8tement', 'url_to_sanitize' => 'http://user:pass@example.com:8080/subdir/test1?modèle=numérique#complètement', ], - 'with_invalid_utf8_in_url' => [ + 'with_invalid_utf8_in_url' => [ 'expected' => 'https://example.com/', 'url_to_sanitize' => 'https://example.com/%e2%28%a1-aaaaaa', ], - 'with_reserved_chars_in_url' => [ + 'with_reserved_chars_in_url' => [ 'expected' => 'https://www.example.com/%c2%a9-2020/?email=test%40example.com&%c3%a2lt=%c2%a9%c3%b2d%c3%abs', 'url_to_sanitize' => 'https://www.example.com/©-2020/?email=test@example.com&âlt=©òdës', ], - 'with_ipv6_in_url' => [ + 'with_ipv6_in_url' => [ 'expected' => 'https://user:pass@[fc00::1]:8443/subdir/test1/?query=test2#fragment', 'url_to_sanitize' => 'https://user:pass@[fc00::1]:8443/subdir/test1/?query=test2#fragment', ], - 'html_injection' => [ + 'html_injection' => [ 'expected' => 'https://onafterprintconsole.log0', 'url_to_sanitize' => 'https://" onafterprint="console.log(0)', ], + // Related issue: https://github.com/Yoast/wordpress-seo/issues/22903. + 'with_non_encoded_chinese_url' => [ + 'expected' => 'https://example.com/%e4%b8%ad%e6%96%87%e8%b7%af%e5%be%84', + 'url_to_sanitize' => 'https://example.com/中文路径', + ], + 'with_mixed_encoded_and_unencoded_non_latin_url' => [ + 'expected' => 'https://example.com/%d0%bf%d1%83%d1%82%d1%8c/%da%af%d8%b1%d9%88%d9%87', + 'url_to_sanitize' => 'https://example.com/путь/%da%af%d8%b1%d9%88%d9%87', + ], + // The pre-encoding must not touch the authority: non-Latin userinfo is encoded once, not double-encoded. + 'with_non_latin_userinfo' => [ + 'expected' => 'https://%c3%bcser:p%c3%a4ss@example.com/', + 'url_to_sanitize' => 'https://üser:päss@example.com/', + ], ]; } } diff --git a/yarn.lock b/yarn.lock index 7dd5dbad843..520df58f3b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18179,9 +18179,9 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= fast-uri@^3.0.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" - integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== + version "3.1.4" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.4.tgz#3b3daf9ce68f41f956df0b505132c0cfce9ec7af" + integrity sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw== fast-xml-builder@^1.1.5: version "1.2.0" @@ -20331,9 +20331,9 @@ homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1: parse-passwd "^1.0.0" hono@^4.11.4: - version "4.12.26" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.26.tgz#450edfd64aad96cccc36829d63ec1430272e3ef8" - integrity sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw== + version "4.12.31" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.31.tgz#2ccaaf3ccd82db372f169c5b39c065c31ff36294" + integrity sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg== hooker@^0.2.3, hooker@~0.2.3: version "0.2.3" @@ -34119,7 +34119,7 @@ zod@3.23.8: resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== -zod@^3.24.1, zod@^3.24.2: +zod@^3.24.1, zod@^3.24.2, zod@^3.25.76: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== From b18959130c39908677e68f2e431722e20128e5a8 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Mon, 27 Jul 2026 15:47:15 +0200 Subject: [PATCH 10/20] fix(bulk-editor): align CS threshold and fix tour comment nits - Lower YOASTCS_THRESHOLD_ERRORS to 2385 to match the branch (the specific get_script_data() return type removes one MissingTraversableTypeHint error). - Fix comment typos (exists, overlaid) and reword the spotlight CSS comment. Co-Authored-By: Claude Opus 4.8 --- composer.json | 2 +- css/src/bulk-editor-page.css | 4 ++-- .../js/src/bulk-editor/components/tour/use-tour-anchor.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 6267384bbdb..c017fe5d843 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=2386", + "@putenv YOASTCS_THRESHOLD_ERRORS=2385", "@putenv YOASTCS_THRESHOLD_WARNINGS=254", "Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds" ], diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index ef9b9506b30..5a395068f69 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -6,7 +6,7 @@ padding-left: 0 !important; } - /* Keep the admin menu above the guided-tour spotlight dim so it isn't overlayed. */ + /* Keep the admin menu above the guided-tour spotlight dim so it isn't overlaid. */ #adminmenuback { z-index: 11; } @@ -113,7 +113,7 @@ @apply yst-outline-none; } -/* The in-app guided-tour. It is decorative only so prevents interaction */ +/* 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; } 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 index 57410d70cae..875d965fe69 100644 --- a/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js +++ b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js @@ -39,7 +39,7 @@ const findVisibleTarget = ( selector ) => [ ...document.querySelectorAll( selector ) ].find( ( element ) => element.offsetParent !== null ) ?? null; /** - * Whether an element exsists and is visible. + * Whether an element exists and is visible. * * @param {HTMLElement} element The element. * From dca3457439a013a17f75ae33c5b840a7f361e809 Mon Sep 17 00:00:00 2001 From: JordiPV Date: Mon, 27 Jul 2026 16:03:11 +0200 Subject: [PATCH 11/20] fix(bulk-editor): tighten single-region tour spotlight to uniform 6px padding The appearance-tabs step looked too loose (8px sides + 24px right). Use a small uniform padding so the spotlight hugs the target and the popover sits closer, matching the design. Co-Authored-By: Claude Opus 4.8 --- .../src/bulk-editor/components/tour/use-tour-anchor.js | 4 ++-- .../js/tests/bulk-editor/tour/use-tour-anchor.test.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) 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 index 875d965fe69..100242625eb 100644 --- a/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js +++ b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js @@ -5,8 +5,8 @@ import { useState, useEffect } from "@wordpress/element"; const TARGET_WAIT_MS = 2000; const TARGET_POLL_MS = 100; -// Space (px) around a single-region spotlight. The right side gets extra room for the gap to the popover. -const SPOTLIGHT_PADDING = { top: 8, right: 24, bottom: 8, left: 8 }; +// 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; 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 index 48cfe228951..f49ae634295 100644 --- a/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js +++ b/packages/js/tests/bulk-editor/tour/use-tour-anchor.test.js @@ -74,9 +74,9 @@ describe( "useTourAnchor", () => { const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true ) ); - // The target rect grows by the spotlight padding: 8 all round plus an extra 24 on the right. - expect( result.current.spotlight.rects ).toEqual( [ { top: 2, left: 12, width: 132, height: 46, rx: 8 } ] ); - expect( result.current.spotlight.bounds ).toEqual( { top: "2px", left: "12px", width: "132px", height: "46px" } ); + // 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(); } ); @@ -88,8 +88,8 @@ describe( "useTourAnchor", () => { const { result } = renderHook( () => useTourAnchor( "[data-tour-id=\"x\"]", true, { endSelector: "[data-tour-highlight-end]" } ) ); - // Height spans from the padded top (2) to the end element's bottom (60), not the target's own 90-tall rect. - expect( result.current.spotlight.rects ).toEqual( [ { top: 2, left: 12, width: 132, height: 58, rx: 8 } ] ); + // 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", () => { From c8312b5e5b85c93e3d73863e9ce2dbd2ccce383b Mon Sep 17 00:00:00 2001 From: JordiPV Date: Tue, 28 Jul 2026 13:22:49 +0200 Subject: [PATCH 12/20] Enhance guided tour functionality with RTL support and child selection --- css/src/bulk-editor-page.css | 13 ++ .../components/tour/bulk-editor-tour.js | 1 + .../bulk-editor/components/tour/tour-card.js | 20 +- .../bulk-editor/components/tour/tour-steps.js | 1 + .../components/tour/use-tour-anchor.js | 173 +++++++++++++----- .../bulk-editor/tour/use-tour-anchor.test.js | 14 ++ 6 files changed, 163 insertions(+), 59 deletions(-) diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index 5a395068f69..2b1ccf3f559 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -117,3 +117,16 @@ .yst-root .yst-tour-spotlight { pointer-events: none; } + +/* In 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; +} diff --git a/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js b/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js index d737c954e12..6c229d68762 100644 --- a/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js +++ b/packages/js/src/bulk-editor/components/tour/bulk-editor-tour.js @@ -58,6 +58,7 @@ export const BulkEditorTour = ( { onSelectAll, onDeselectAll, hasSelection } ) = const { spotlight } = useTourAnchor( `[data-tour-id="${ step.tourId }"]`, isActive, { endSelector: step.highlightEndSelector, perChild: step.highlightChildren, + childSelector: step.highlightChildrenSelector, } ); const finish = useCallback( () => { diff --git a/packages/js/src/bulk-editor/components/tour/tour-card.js b/packages/js/src/bulk-editor/components/tour/tour-card.js index a0c0f36c9c6..42ae672f8ef 100644 --- a/packages/js/src/bulk-editor/components/tour/tour-card.js +++ b/packages/js/src/bulk-editor/components/tour/tour-card.js @@ -90,7 +90,7 @@ export const TourCard = ( { isVisible={ true } setIsVisible={ handleVisibilityChange } position={ position } - className={ className } + className={ `yst-bulk-editor-tour-card ${ className }`.trim() } onKeyDown={ handleKeyDown } > <> @@ -99,23 +99,25 @@ export const TourCard = ( {
- + { title }
- + { content }
- { 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 - ) } + + { 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 + ) } +
{ onBack &&