From 2af957b0bf7f23c254cb8f03c5870119066035b3 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 13:17:30 +0200 Subject: [PATCH 01/31] Register the bulk action for each of the supported content types --- ...osts-overview-bulk-actions-integration.php | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php diff --git a/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php new file mode 100644 index 00000000000..1340685aaba --- /dev/null +++ b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php @@ -0,0 +1,161 @@ +content_types_repository = $content_types_repository; + $this->current_page_helper = $current_page_helper; + } + + /** + * Returns the conditionals based on which this loadable should be active. + * + * @return array The conditionals. + */ + public static function get_conditionals() { + return [ Admin_Conditional::class ]; + } + + /** + * Initializes the integration. + * + * This is the place to register hooks and filters. + * + * @return void + */ + public function register_hooks() { + // The supported post types are only known once post types are registered, so defer collecting them. + \add_action( 'admin_init', [ $this, 'register_bulk_actions' ] ); + } + + /** + * Registers the bulk action on the overviews of the post types the bulk editor supports. + * + * @return void + */ + public function register_bulk_actions() { + // The same capability that gates access to the bulk editor page itself. + if ( ! \current_user_can( 'wpseo_manage_options' ) ) { + return; + } + + foreach ( $this->content_types_repository->get_content_types() as $content_type ) { + \add_filter( 'bulk_actions-edit-' . $content_type['name'], [ $this, 'add_bulk_action' ] ); + \add_filter( 'handle_bulk_actions-edit-' . $content_type['name'], [ $this, 'handle_bulk_action' ], 10, 3 ); + } + } + + /** + * Adds the bulk editor entry to the bulk-actions dropdown. + * + * @param array> $actions The bulk actions. + * + * @return array> The bulk actions. + */ + public function add_bulk_action( $actions ) { + // Trashed posts are not listed in the bulk editor, so the trash view does not get the entry. + if ( $this->current_page_helper->is_trash_overview() ) { + return $actions; + } + + // The arrow signals that this action navigates to another page instead of processing in place. + // VS15 (U+FE0E) forces text presentation, so wp-admin's emoji script leaves the glyph alone; + // RTL admins get the mirrored glyph. + $arrow = ( \is_rtl() ) ? "\u{2196}\u{FE0E}" : "\u{2197}\u{FE0E}"; + + // A nested array renders as an optgroup (since WP 5.6): the default actions stay first, followed + // by a visually separated "Yoast SEO" group holding the entry. + $actions[ \__( 'Yoast SEO', 'wordpress-seo' ) ] = [ + self::BULK_ACTION => \__( 'Bulk edit', 'wordpress-seo' ) . ' ' . $arrow, + ]; + + return $actions; + } + + /** + * Handles the bulk action by sending the user to the bulk editor page with the selection carried over. + * + * @param string $redirect_url The URL the overview would redirect to. + * @param string $action The bulk action being handled. + * @param array $post_ids The selected post IDs. + * + * @return string The URL to redirect to. + */ + public function handle_bulk_action( $redirect_url, $action, $post_ids ) { + if ( $action !== self::BULK_ACTION ) { + return $redirect_url; + } + + // The filter only fires on the overview of the post type it was registered for, so the + // current screen carries the content type to preselect. + $screen = \get_current_screen(); + if ( $screen === null || $screen->post_type === '' ) { + return $redirect_url; + } + + $args = [ + 'page' => Bulk_Editor_Integration::PAGE, + Bulk_Editor_Integration::CONTENT_TYPE_PARAM => $screen->post_type, + ]; + + $ids = \array_values( + \array_unique( + \array_filter( + \array_map( 'intval', (array) $post_ids ), + static function ( $id ) { + return $id > 0; + }, + ), + ), + ); + + if ( $ids !== [] ) { + // Only the first batch can end up selected; carrying more would only risk hitting URL length limits. + $args[ Bulk_Editor_Integration::POST_IDS_PARAM ] = \implode( ',', \array_slice( $ids, 0, Batch_Limit::MAX_ITEMS ) ); + $args[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = \count( $ids ); + } + + return \add_query_arg( $args, \admin_url( 'admin.php' ) ); + } +} From 545f4f869d863528c60ee6421be90c1335032186 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 13:18:49 +0200 Subject: [PATCH 02/31] Utility to check if the user is on the trash page --- src/helpers/current-page-helper.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/helpers/current-page-helper.php b/src/helpers/current-page-helper.php index 39e632fee47..f647a666294 100644 --- a/src/helpers/current-page-helper.php +++ b/src/helpers/current-page-helper.php @@ -429,6 +429,18 @@ public function get_current_yoast_seo_page() { return ''; } + /** + * Returns whether the current admin overview (list table) shows the trash. + * + * Mirrors the check WP_Posts_List_Table itself uses to decide it is on the trash view. + * + * @return bool Whether the current admin overview shows the trash. + */ + public function is_trash_overview(): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. + return isset( $_REQUEST['post_status'] ) && $_REQUEST['post_status'] === 'trash'; + } + /** * Checks if the current global post is the privacy policy page. * From 87efb1761cde1355cac12eeffdb63d986f11c43b Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 14:04:55 +0200 Subject: [PATCH 03/31] Fetch the selected posts coming from the overview --- .../bulk-editor-integration.php | 107 ++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index 1c0b5776d5d..70461c6c24d 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -6,6 +6,7 @@ use WPSEO_Admin_Asset_Manager; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository; +use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository; use Yoast\WP\SEO\Conditionals\Admin_Conditional; use Yoast\WP\SEO\General\User_Interface\General_Page_Integration; @@ -30,6 +31,21 @@ class Bulk_Editor_Integration implements Integration_Interface { */ public const ASSETS_NAME = 'bulk-editor-page'; + /** + * The URL parameter carrying the content type to preselect. + */ + public const CONTENT_TYPE_PARAM = 'content_type'; + + /** + * The URL parameter carrying the post IDs to preselect, comma-separated. + */ + public const POST_IDS_PARAM = 'post_ids'; + + /** + * The URL parameter carrying how many posts were selected on the overview the user came from. + */ + public const SELECTED_COUNT_PARAM = 'selected_count'; + /** * Holds the WPSEO_Admin_Asset_Manager. * @@ -144,6 +160,7 @@ public function register_hooks() { if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) { \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); \add_action( 'in_admin_header', [ $this, 'remove_notices' ], \PHP_INT_MAX ); + \add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] ); } } @@ -209,26 +226,102 @@ public function enqueue_assets() { * @return array>> The script data. */ public function get_script_data() { + $content_types = $this->content_types_repository->get_content_types(); + return [ - 'contentTypes' => $this->content_types_repository->get_content_types(), - 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), + 'contentTypes' => $content_types, + 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), // These must stay server-generated URLs: the bulk editor assigns them to window.location.href for its // "Back to Tools" / logo navigation. If a link ever derives from request input, validate it with // 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(), + 'initialSelection' => $this->get_initial_selection( $content_types ), + ]; + } + + /** + * Returns the selection carried over from a post overview bulk action, if any. + * + * The parameters only decide which rows start out selected in the app; the REST endpoints + * enforce the actual per-post edit access when anything is saved. + * + * @param array> $content_types The available content types. + * + * @return array> The content type, post IDs and overview selection count. + */ + private function get_initial_selection( array $content_types ): array { + $initial_selection = [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, ]; + + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reason: read-only display state, no action is taken. + if ( ! isset( $_GET[ self::CONTENT_TYPE_PARAM ] ) || ! \is_string( $_GET[ self::CONTENT_TYPE_PARAM ] ) ) { + return $initial_selection; + } + + $content_type = \sanitize_text_field( \wp_unslash( $_GET[ self::CONTENT_TYPE_PARAM ] ) ); + if ( ! \in_array( $content_type, \array_column( $content_types, 'name' ), true ) ) { + return $initial_selection; + } + $initial_selection['contentType'] = $content_type; + + if ( isset( $_GET[ self::POST_IDS_PARAM ] ) && \is_string( $_GET[ self::POST_IDS_PARAM ] ) ) { + $post_ids = \explode( ',', \sanitize_text_field( \wp_unslash( $_GET[ self::POST_IDS_PARAM ] ) ) ); + $post_ids = \array_values( + \array_unique( + \array_filter( + \array_map( 'intval', $post_ids ), + static function ( $id ) { + return $id > 0; + }, + ), + ), + ); + $post_ids = \array_slice( $post_ids, 0, Batch_Limit::MAX_ITEMS ); + + $initial_selection['postIds'] = $post_ids; + $initial_selection['selectedCount'] = \count( $post_ids ); + } + + if ( $initial_selection['postIds'] !== [] && isset( $_GET[ self::SELECTED_COUNT_PARAM ] ) ) { + // The count can only grow beyond the carried IDs, never shrink below them. + $initial_selection['selectedCount'] = \max( + $initial_selection['selectedCount'], + (int) $_GET[ self::SELECTED_COUNT_PARAM ], + ); + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + return $initial_selection; + } + + /** + * Registers the carried-over selection parameters as removable, so WordPress cleans them from the + * address bar once the page has picked them up. + * + * @param array $removable_query_args The removable query args. + * + * @return array The removable query args. + */ + public function add_removable_query_args( $removable_query_args ) { + $removable_query_args[] = self::POST_IDS_PARAM; + $removable_query_args[] = self::SELECTED_COUNT_PARAM; + + return $removable_query_args; } /** From 1155219dc73411da8deb6f08574ecf3f4d25f5c9 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 14:05:44 +0200 Subject: [PATCH 04/31] Add the optional include list to support the new custom filter --- src/bulk-editor/domain/posts/posts-query.php | 30 +++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/bulk-editor/domain/posts/posts-query.php b/src/bulk-editor/domain/posts/posts-query.php index a6af78a6209..a3100f8dd1f 100644 --- a/src/bulk-editor/domain/posts/posts-query.php +++ b/src/bulk-editor/domain/posts/posts-query.php @@ -53,6 +53,13 @@ class Posts_Query { */ private $author_id; + /** + * The post IDs to limit posts to, or an empty array for no restriction. + * + * @var array + */ + private $include; + /** * The constructor. * @@ -62,6 +69,7 @@ class Posts_Query { * @param string $search The search term, or an empty string for no search. * @param array $statuses The post statuses to include. * @param int|null $author_id The author to limit posts to, or null for no author restriction. + * @param array $include The post IDs to limit posts to, or an empty array for no restriction. */ public function __construct( string $content_type, @@ -69,7 +77,8 @@ public function __construct( int $per_page, string $search, array $statuses, - ?int $author_id = null + ?int $author_id = null, + array $include = [] ) { $this->content_type = $content_type; $this->page = $page; @@ -77,6 +86,7 @@ public function __construct( $this->search = $search; $this->statuses = $statuses; $this->author_id = $author_id; + $this->include = $include; } /** @@ -151,6 +161,24 @@ public function has_author_filter(): bool { return $this->author_id !== null; } + /** + * Returns the post IDs to limit posts to. + * + * @return array The post IDs, or an empty array for no restriction. + */ + public function get_include(): array { + return $this->include; + } + + /** + * Returns whether posts are limited to specific post IDs. + * + * @return bool Whether a post ID restriction is set. + */ + public function has_include(): bool { + return $this->include !== []; + } + /** * Returns the zero-based offset of the requested page. * From 7c3871b89a7aacc018d8d9c627f525b33af9c6a1 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 14:09:08 +0200 Subject: [PATCH 05/31] Use the new custom filter --- .../infrastructure/posts/indexable-posts-collector.php | 4 ++++ .../infrastructure/posts/post-meta-posts-collector.php | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index 929b0e2f07a..e2b602d77ec 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -129,6 +129,10 @@ private function build_query( Posts_Query $query ): ORM { $builder->where( 'author_id', $query->get_author_id() ); } + if ( $query->has_include() ) { + $builder->where_in( 'object_id', $query->get_include() ); + } + if ( $query->has_search() ) { $this->apply_search( $builder, $query->get_search() ); } diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index c6e1280c37e..6d03c097acc 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -112,7 +112,7 @@ protected function run_query( Posts_Query $query ): WP_Query { * * @param Posts_Query $query The query describing the page to collect. * - * @return array> The WP_Query arguments. + * @return array|array> The WP_Query arguments. */ private function build_query_args( Posts_Query $query ): array { $args = [ @@ -135,6 +135,10 @@ private function build_query_args( Posts_Query $query ): array { $args['author'] = $query->get_author_id(); } + if ( $query->has_include() ) { + $args['post__in'] = $query->get_include(); + } + return $args; } From 9b58b772d59f0dbee606a3d9567eeb79074a7919 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 14:10:47 +0200 Subject: [PATCH 06/31] Pass the list of the elements shown by the new custom filter --- src/bulk-editor/user-interface/posts-route.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/bulk-editor/user-interface/posts-route.php b/src/bulk-editor/user-interface/posts-route.php index 12fcb9a8a46..cd4a70e44d8 100644 --- a/src/bulk-editor/user-interface/posts-route.php +++ b/src/bulk-editor/user-interface/posts-route.php @@ -151,6 +151,17 @@ public function register_routes() { ], 'description' => 'The post statuses to include.', ], + 'include' => [ + 'required' => false, + 'type' => 'array', + 'default' => [], + 'maxItems' => self::MAX_PER_PAGE, + 'items' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.', + ], ], 'callback' => [ $this, 'get_posts' ], 'permission_callback' => [ $this, 'check_permissions' ], @@ -189,6 +200,9 @@ public function get_posts( WP_REST_Request $request ) { $author_id = $this->user_helper->get_current_user_id(); } + // The schema already coerces the items to positive integers; deduplicate on top of that. + $include = \array_values( \array_unique( \array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); + $query = new Posts_Query( $content_type, (int) $request->get_param( 'page' ), @@ -196,6 +210,7 @@ public function get_posts( WP_REST_Request $request ) { (string) $request->get_param( 'search' ), $statuses, $author_id, + $include, ); // Posts the current user cannot edit are returned locked and without their SEO data; the per-post From 21f9e5ee9b1ea3349781453370befe75b6d553c2 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:05:16 +0200 Subject: [PATCH 07/31] Take into account the overview filter --- .../js/src/bulk-editor/services/use-posts.js | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/js/src/bulk-editor/services/use-posts.js b/packages/js/src/bulk-editor/services/use-posts.js index 37fe48c85da..c78f9abf7da 100644 --- a/packages/js/src/bulk-editor/services/use-posts.js +++ b/packages/js/src/bulk-editor/services/use-posts.js @@ -63,6 +63,8 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => const search = useSelect( ( select ) => select( STORE_NAME ).selectSearch(), [] ); const page = useSelect( ( select ) => select( STORE_NAME ).selectPage(), [] ); const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] ); + const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] ); + const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] ); const endpoint = dataProvider.getEndpoint( "posts" ); @@ -82,18 +84,24 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => setState( ( previous ) => ( { ...previous, isPending: true } ) ); + const params = { + /* eslint-disable camelcase -- The REST endpoint expects snake_case query parameters. */ + content_type: contentType, + per_page: String( PAGE_SIZE ), + page: String( page ), + search, + status: statuses, + /* eslint-enable camelcase */ + }; + // The "Overview selection" filter narrows the list to the posts carried over from the WP admin overview. + if ( isOverviewFilterActive && overviewIds.length > 0 ) { + params.include = overviewIds.map( String ); + } + remoteDataProvider .fetchJson( endpoint, - { - /* eslint-disable camelcase -- The REST endpoint expects snake_case query parameters. */ - content_type: contentType, - per_page: String( PAGE_SIZE ), - page: String( page ), - search, - status: statuses, - /* eslint-enable camelcase */ - }, + params, { signal: current.signal } ) .then( ( response ) => { @@ -109,7 +117,7 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => } ); return () => current.abort(); - }, [ endpoint, contentType, remoteDataProvider, search, page, statuses ] ); + }, [ endpoint, contentType, remoteDataProvider, search, page, statuses, overviewIds, isOverviewFilterActive ] ); return { ...state, updateItem }; }; From ace38d765182945bb967d9194b062049587cbae0 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:06:51 +0200 Subject: [PATCH 08/31] Add the overview filter logic --- packages/js/src/bulk-editor/store/query.js | 24 ++++++++++++++++--- .../js/src/bulk-editor/store/selection.js | 12 +++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/js/src/bulk-editor/store/query.js b/packages/js/src/bulk-editor/store/query.js index 9edb4de0173..4f2e591b3b8 100644 --- a/packages/js/src/bulk-editor/store/query.js +++ b/packages/js/src/bulk-editor/store/query.js @@ -3,9 +3,17 @@ import { get } from "lodash"; import { activeContentTypeActions } from "./active-content-type"; /** - * @returns {{search: string, page: number, statuses: string[]}} The initial table query state. + * @returns {{search: string, page: number, statuses: string[], overviewIds: number[], isOverviewFilterActive: boolean}} The initial query state. */ -export const createInitialQueryState = () => ( { search: "", page: 1, statuses: [] } ); +export const createInitialQueryState = () => ( { + search: "", + page: 1, + statuses: [], + // The post IDs carried over from the WP admin overview, backing the "Overview selection" filter; + // empty when the user did not come in via the overview's bulk action. + overviewIds: [], + isOverviewFilterActive: false, +} ); const slice = createSlice( { name: "query", @@ -24,11 +32,19 @@ const slice = createSlice( { state.statuses = payload; state.page = 1; }, + // Toggling the overview filter resets to the first page, like any other change to the result set. + setOverviewFilterActive: ( state, { payload } ) => { + state.isOverviewFilterActive = Boolean( payload ); + state.page = 1; + }, }, extraReducers: ( builder ) => { - // Switching content type resets to the first page: the current page may not exist in the new content type's results. + // Switching content type resets to the first page: the current page may not exist in the new content + // type's results. The overview selection belongs to the content type it was made for, so it goes too. builder.addCase( activeContentTypeActions.setActiveContentType, ( state ) => { state.page = 1; + state.overviewIds = []; + state.isOverviewFilterActive = false; } ); }, } ); @@ -37,6 +53,8 @@ export const querySelectors = { selectSearch: ( state ) => get( state, "query.search", "" ), selectPage: ( state ) => get( state, "query.page", 1 ), selectStatuses: ( state ) => get( state, "query.statuses", [] ), + selectOverviewIds: ( state ) => get( state, "query.overviewIds", [] ), + selectIsOverviewFilterActive: ( state ) => get( state, "query.isOverviewFilterActive", false ), selectQuery: ( state ) => get( state, "query", createInitialQueryState() ), }; diff --git a/packages/js/src/bulk-editor/store/selection.js b/packages/js/src/bulk-editor/store/selection.js index 746cb48ba20..cb32c8f7447 100644 --- a/packages/js/src/bulk-editor/store/selection.js +++ b/packages/js/src/bulk-editor/store/selection.js @@ -4,10 +4,13 @@ import { activeContentTypeActions } from "./active-content-type"; import { queryActions } from "./query"; /** - * @returns {Object} The initial selection state: no rows selected. + * @returns {Object} The initial selection state: no rows selected, no selection carried over from the WP admin overview. */ export const createInitialSelectionState = () => ( { selectedIds: [], + // How many items were selected on the WP admin overview when the user came in via its bulk action; drives the + // "only the first 20 are selected" notice. 0 when the user did not come in that way (or the notice was dismissed). + preselectedTotal: 0, } ); const slice = createSlice( { @@ -23,20 +26,27 @@ const slice = createSlice( { }, selectAll: ( state, { payload } ) => { state.selectedIds = [ ...payload ]; + // An explicit select-all replaces the carried-over selection, so its notice would be stale. + state.preselectedTotal = 0; }, deselectAll: () => createInitialSelectionState(), + dismissPreselectionNotice: ( state ) => { + state.preselectedTotal = 0; + }, }, extraReducers: ( builder ) => { // Any change to the shown result set resets the selection: the new set may no longer contain the selected rows. builder.addCase( queryActions.setStatuses, () => createInitialSelectionState() ); builder.addCase( queryActions.setSearch, () => createInitialSelectionState() ); builder.addCase( queryActions.setPage, () => createInitialSelectionState() ); + builder.addCase( queryActions.setOverviewFilterActive, () => createInitialSelectionState() ); builder.addCase( activeContentTypeActions.setActiveContentType, () => createInitialSelectionState() ); }, } ); export const selectionSelectors = { selectSelectedIds: ( state ) => get( state, "selection.selectedIds", [] ), + selectPreselectedTotal: ( state ) => get( state, "selection.preselectedTotal", 0 ), }; export const selectionActions = slice.actions; From 616b12f47ffe51ed3886ff610c10b9491462bc15 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:23:49 +0200 Subject: [PATCH 09/31] Get alredy-selected elements (if any) coming from the overview page --- packages/js/src/bulk-editor/initialize.js | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js index 9b2b05b9e6d..3ae1f02c740 100644 --- a/packages/js/src/bulk-editor/initialize.js +++ b/packages/js/src/bulk-editor/initialize.js @@ -24,6 +24,33 @@ window.yoast.bulkEditor = window.yoast.bulkEditor || {}; window.yoast.bulkEditor.components = { ...window.yoast.bulkEditor.components, UpsellModal, GenericAlert }; window.yoast.bulkEditor.hooks = { ...window.yoast.bulkEditor.hooks, useAiUpsell }; +/** + * Builds the store state for a selection carried over from a WP admin overview bulk action. + * + * @param {Object} [initialSelection] The carried-over selection ({ contentType, postIds, selectedCount }), if any. + * + * @returns {Object} The seeded state: the active content type, the selection and the overview filter. + */ +export const getPreselectionState = ( initialSelection = {} ) => { + const selectedIds = ( Array.isArray( initialSelection.postIds ) ? initialSelection.postIds : [] ) + .map( Number ) + .filter( ( id ) => Number.isInteger( id ) && id > 0 ) + .slice( 0, BULK_UPDATE_BATCH_SIZE ); + + return { + // An empty or unknown name resolves to the first available content type in the app. + activeContentType: typeof initialSelection.contentType === "string" ? initialSelection.contentType : "", + selection: { + selectedIds, + preselectedTotal: selectedIds.length > 0 ? Math.max( Number( initialSelection.selectedCount ) || 0, selectedIds.length ) : 0, + }, + query: { + overviewIds: selectedIds, + isOverviewFilterActive: selectedIds.length > 0, + }, + }; +}; + domReady( () => { const root = document.getElementById( ROOT_ID ); if ( ! root ) { From f59dd9ca818ce5d1338b0e0bd7c44ac071b080df Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:24:05 +0200 Subject: [PATCH 10/31] Add the overview filter --- .../components/bulk-editor-filters.js | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/js/src/bulk-editor/components/bulk-editor-filters.js b/packages/js/src/bulk-editor/components/bulk-editor-filters.js index 5cd441a7bab..3b678f00d7a 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-filters.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-filters.js @@ -6,14 +6,16 @@ import { Badge, Button, CheckboxGroup, Popover, useSvgAria } from "@yoast/ui-lib import { STORE_NAME } from "../constants"; /** - * The Filters button and popover: narrows the table by post status. - * A badge shows how many filters are applied. + * The Filters button and popover: narrows the table by post status and, when a selection was carried + * over from the WP admin overview, by that selection. A badge shows how many filters are applied. * * @returns {JSX.Element} The filters control. */ export const BulkEditorFilters = () => { const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] ); - const { setStatuses } = useDispatch( STORE_NAME ); + const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] ); + const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] ); + const { setStatuses, setOverviewFilterActive } = useDispatch( STORE_NAME ); const [ isOpen, setIsOpen ] = useState( false ); const containerRef = useRef( null ); const triggerRef = useRef( null ); @@ -26,6 +28,15 @@ export const BulkEditorFilters = () => { { value: "draft", label: __( "Draft", "wordpress-seo" ) }, ], [] ); + const overviewOptions = useMemo( () => [ + { value: "overview", label: __( "Overview selection", "wordpress-seo" ) }, + ], [] ); + + const onChangeOverviewFilter = useCallback( + ( values ) => setOverviewFilterActive( values.includes( "overview" ) ), + [ setOverviewFilterActive ] + ); + const toggleOpen = useCallback( () => setIsOpen( ( open ) => ! open ), [] ); useEffect( () => { @@ -55,7 +66,7 @@ export const BulkEditorFilters = () => { }; }, [ isOpen ] ); - const appliedCount = statuses.length; + const appliedCount = statuses.length + ( isOverviewFilterActive ? 1 : 0 ); return (
@@ -71,6 +82,15 @@ export const BulkEditorFilters = () => { className="before:yst-hidden !yst-top-full yst-mt-1 yst-p-3 yst-shadow-lg" aria-label={ __( "Filters", "wordpress-seo" ) } > + { overviewIds.length > 0 && ( + + ) } Date: Wed, 22 Jul 2026 16:24:31 +0200 Subject: [PATCH 11/31] Notice to be shown when more than 20 elements were selected in the overview page --- .../components/overview-selection-notice.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/js/src/bulk-editor/components/overview-selection-notice.js diff --git a/packages/js/src/bulk-editor/components/overview-selection-notice.js b/packages/js/src/bulk-editor/components/overview-selection-notice.js new file mode 100644 index 00000000000..00c749e2508 --- /dev/null +++ b/packages/js/src/bulk-editor/components/overview-selection-notice.js @@ -0,0 +1,54 @@ +import SolidXIcon from "@heroicons/react/solid/XIcon"; +import { __, sprintf } from "@wordpress/i18n"; +import { Alert, useSvgAria } from "@yoast/ui-library"; +import { BULK_UPDATE_BATCH_SIZE } from "../constants"; + +/** + * Builds the notice message. + * + * @param {string} noun The lowercase content type label, e.g. "posts". + * + * @returns {string} The notice message. + */ +const getMessage = ( noun ) => sprintf( + /* translators: %1$d expands to the maximum number of items at a time, %2$s to the lowercase content type label, e.g. "posts". */ + __( "Only the first %1$d %2$s from your selection were carried over. The bulk editor supports up to %1$d %2$s at a time.", "wordpress-seo" ), + BULK_UPDATE_BATCH_SIZE, + noun +); + +/** + * The notice shown when the user arrived from a WP admin overview with more items selected than the + * bulk editor can handle in one batch: only the first batch stays selected. Renders nothing while the + * whole selection fits the batch. + * + * @param {Object} props The props. + * @param {number} props.total The number of items that were selected on the overview. + * @param {string} [props.contentTypeLabel] The active content type label (plural), used in the copy. + * @param {Function} props.onDismiss Dismisses the notice. + * + * @returns {?JSX.Element} The notice, or null when the whole selection fits the batch. + */ +export const OverviewSelectionNotice = ( { total, contentTypeLabel, onDismiss } ) => { + const svgAriaProps = useSvgAria(); + + if ( total <= BULK_UPDATE_BATCH_SIZE ) { + return null; + } + + const noun = contentTypeLabel ? contentTypeLabel.toLowerCase() : __( "items", "wordpress-seo" ); + + return ( + + { getMessage( noun ) } + + + ); +}; From c3767951e6f8c655d7e2f7a11149c2b6e0f1ee58 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:26:01 +0200 Subject: [PATCH 12/31] Refactor to lower the complexity --- .../components/bulk-editor-content.js | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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..632e81cb3b1 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-content.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-content.js @@ -42,6 +42,28 @@ export const getSelectionView = ( isLoading, selectedIds, items, total ) => { }; }; +/** + * Decides whether the bulk-actions band row is expanded. + * + * A selection only warrants the band while AI is enabled (the AI affordances are its only selection-driven + * occupant); with AI off the band collapses. Unsaved manual edits are a separate, non-AI occupant, so they + * keep it open regardless of the AI toggle. External pending changes (Premium's AI suggestions) also keep + * it open: a filter, search, or page change clears the selection but must leave the pending suggestions + * actionable. The overview-selection truncation notice lives in the band's notices region, so it opens the + * band too. + * + * @param {Object} view The view state. + * @param {boolean} view.hasSelection Whether any rows are selected. + * @param {boolean} view.isAiEnabled Whether the AI feature is enabled. + * @param {boolean} view.hasUnsavedEdits Whether a row has unsaved manual edits. + * @param {boolean} view.hasExternalPendingChanges Whether an external plugin reports pending changes. + * @param {boolean} view.hasOverviewNotice Whether the overview-selection truncation notice must show. + * + * @returns {boolean} Whether the band is expanded. + */ +export const shouldShowBulkActions = ( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ) => + ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges || hasOverviewNotice; + /** * The bulk editor content. * @@ -194,12 +216,7 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy onDismissSaveError={ editing.dismissSaveError } /> } - // A selection only warrants the band while AI is enabled (the AI affordances are its only - // selection-driven occupant); with AI off the band collapses. Unsaved manual edits are a - // separate, non-AI occupant, so they keep it open regardless of the AI toggle. External - // pending changes (Premium's AI suggestions) also keep it open: a filter, search, or page - // change clears the selection but must leave the pending suggestions actionable. - showBulkActions={ ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges } + showBulkActions={ showBulkActions } filters={ } isLoading={ isPending } hasExternalPendingChanges={ hasExternalPendingChanges } From 0f7ba452de2389cf34d464f0f48e87625a5b023d Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:27:06 +0200 Subject: [PATCH 13/31] Use the preselected elements --- .../components/bulk-editor-content.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 632e81cb3b1..774438aa761 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-content.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-content.js @@ -2,7 +2,7 @@ import { Slot } from "@wordpress/components"; import { useDispatch, useSelect } from "@wordpress/data"; import { useCallback, useEffect, useMemo } from "@wordpress/element"; import { __ } from "@wordpress/i18n"; -import { PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../constants"; +import { BULK_UPDATE_BATCH_SIZE, PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../constants"; import { getFieldSets } from "../field-sets"; import { useInlineEdit } from "../hooks/use-inline-edit"; import { usePosts } from "../services/use-posts"; @@ -30,9 +30,11 @@ export const getSelectionView = ( isLoading, selectedIds, items, total ) => { return { isAllSelected: false, isIndeterminate: false, selectedCount: 0, totalCount: 0, hasSelection: false }; } // Only posts the user can edit are selectable, so "all selected" is measured against the editable rows. - const selectableCount = items.filter( ( item ) => item.editable ).length; + // Measured by membership, not count: a selection carried over from the WP admin overview can contain + // rows that are not on the current page. + const selectableIds = items.filter( ( item ) => item.editable ).map( ( item ) => item.id ); const selectedCount = selectedIds.length; - const isAllSelected = selectableCount > 0 && selectedCount === selectableCount; + const isAllSelected = selectableIds.length > 0 && selectableIds.every( ( id ) => selectedIds.includes( id ) ); return { isAllSelected, isIndeterminate: selectedCount > 0 && ! isAllSelected, @@ -85,6 +87,7 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy const { activeFieldSet, selectedIds, + preselectedTotal, isPremium, isAiEnabled, hasExternalPendingChanges, @@ -95,6 +98,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy return { activeFieldSet: store.selectActiveFieldSet(), selectedIds: store.selectSelectedIds(), + // The size of a selection carried over from the WP admin overview; drives the truncation notice. + preselectedTotal: store.selectPreselectedTotal(), isPremium: store.selectPreference( "isPremium", false ), isAiEnabled: store.selectPreference( "isAiEnabled", false ), // An external plugin (e.g. Premium's AI suggestions) reports pending changes so the switch can be guarded. @@ -104,7 +109,9 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy pendingSwitch: store.selectPendingSwitch(), }; }, [] ); - const { requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll } = useDispatch( STORE_NAME ); + const { + requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll, dismissPreselectionNotice, + } = useDispatch( STORE_NAME ); const { data: items = [], total = 0, totalPages = 0, isPending, updateItem } = usePosts( { dataProvider, remoteDataProvider, contentType } ); const { editing, stopEditing } = useInlineEdit( { dataProvider, remoteDataProvider, fieldSets, activeFieldSet, items, updateItem } ); @@ -151,6 +158,9 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy }, [ pendingSwitch, hasUnsavedEdits, hasExternalPendingChanges, onCommitSwitch ] ); const { isAllSelected, isIndeterminate, selectedCount, totalCount, hasSelection } = getSelectionView( isPending, selectedIds, items, total ); + // The truncation notice for a selection carried over from the WP admin overview, shown in the band's notices region. + const hasOverviewNotice = preselectedTotal > BULK_UPDATE_BATCH_SIZE; + const showBulkActions = shouldShowBulkActions( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ); const onSelectAll = useCallback( () => { if ( ! isPending ) { // Only posts the user can edit are selectable for bulk editing. @@ -214,6 +224,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy isApplyingAll={ editing.isApplyingAll } hasSaveError={ editing.hasSaveError } onDismissSaveError={ editing.dismissSaveError } + preselectedTotal={ preselectedTotal } + onDismissPreselection={ dismissPreselectionNotice } /> } showBulkActions={ showBulkActions } From b02886765727ab03c1b5196b4dc77c4d1cc2d5e2 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:28:00 +0200 Subject: [PATCH 14/31] Add the notice --- .../bulk-editor/components/bulk-action-bar.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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..143239b34fa 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -9,6 +9,7 @@ import { __, _n, sprintf } from "@wordpress/i18n"; import { Alert, Button, Checkbox, DropdownMenu, useSvgAria, useToggleState } from "@yoast/ui-library"; import { BULK_ACTIONS_SLOT, BULK_NOTICES_SLOT, SELECT_MENU_ITEMS_FILTER } from "../constants"; import { useAiUpsell } from "../hooks/use-ai-upsell"; +import { OverviewSelectionNotice } from "./overview-selection-notice"; import { UpsellModal } from "./upsell-modal"; /** @@ -201,10 +202,12 @@ export const ManualSaveErrorNotice = ( { onDismiss } ) => { }; /** - * The Free save-error notice, and the alerts slot Premium fills (e.g. its AI alerts). - * Only rendered on the active tab, so each tab has a single slot to target. + * The overview-selection truncation notice, the Free save-error notice, and the alerts slot Premium fills + * (e.g. its AI alerts). Only rendered on the active tab, so each tab has a single slot to target. * * @param {Object} props The props. + * @param {number} [props.preselectedTotal] How many items were selected on the WP admin overview; shows the truncation notice. + * @param {Function} [props.onDismissPreselection] Dismisses the truncation notice. * @param {boolean} [props.hasSaveError] Whether the last apply-all failed; shows the save-error notice. * @param {Function} [props.onDismissSaveError] Dismisses the save-error notice. * @param {number[]} props.selectedIds The ids of the selected rows, passed to the notices fill. @@ -216,9 +219,11 @@ export const ManualSaveErrorNotice = ( { onDismiss } ) => { * @returns {JSX.Element} The notices region. */ const BulkActionsNotices = ( { - hasSaveError, onDismissSaveError, selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel, + preselectedTotal = 0, onDismissPreselection, hasSaveError, onDismissSaveError, + selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel, } ) => ( <> + { hasSaveError && } (
{ isActive && ( Date: Wed, 22 Jul 2026 16:28:29 +0200 Subject: [PATCH 15/31] Left out by mistake from the previous commit --- packages/js/src/bulk-editor/initialize.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js index 3ae1f02c740..a5bbb68b1db 100644 --- a/packages/js/src/bulk-editor/initialize.js +++ b/packages/js/src/bulk-editor/initialize.js @@ -11,7 +11,7 @@ import { fixWordPressMenuScrolling } from "../shared-admin/helpers"; import { LINK_PARAMS_NAME } from "../shared-admin/store"; import App from "./app"; import { UpsellModal } from "./components/upsell-modal"; -import { PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants"; +import { BULK_UPDATE_BATCH_SIZE, PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants"; import { useAiUpsell } from "./hooks/use-ai-upsell"; import { DataProvider } from "./services"; import registerStore from "./store"; @@ -60,6 +60,7 @@ domReady( () => { registerStore( { initialState: { [ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ), + ...getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ), }, } ); fixWordPressMenuScrolling(); From c8217d710dbb43b730f97d4001c8843de9e3199b Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 22 Jul 2026 16:28:41 +0200 Subject: [PATCH 16/31] Add tests --- .../bulk-editor/bulk-editor-content.test.js | 37 +++- .../bulk-editor/bulk-editor-filters.test.js | 25 ++- .../js/tests/bulk-editor/initialize.test.js | 84 +++++++- .../overview-selection-notice.test.js | 92 +++++++++ .../bulk-editor/services/use-posts.test.js | 34 ++- .../js/tests/bulk-editor/store/query.test.js | 32 ++- .../tests/bulk-editor/store/selection.test.js | 45 +++- .../Domain/Posts/Posts_Query_Test.php | 28 +++ .../Get_Posts_Test.php | 33 +++ .../Add_Removable_Query_Args_Test.php | 33 +++ .../Enqueue_Assets_Test.php | 19 +- .../Get_Initial_Selection_Test.php | 193 ++++++++++++++++++ .../Register_Hooks_Test.php | 2 + ...Overview_Bulk_Actions_Integration_Test.php | 94 +++++++++ .../Add_Bulk_Action_Test.php | 73 +++++++ .../Constructor_Test.php | 34 +++ .../Get_Conditionals_Test.php | 27 +++ .../Handle_Bulk_Action_Test.php | 140 +++++++++++++ .../Register_Bulk_Actions_Test.php | 81 ++++++++ .../Register_Hooks_Test.php | 28 +++ .../Posts_Route/Get_Posts_Test.php | 52 +++++ .../Posts_Route/Register_Routes_Test.php | 11 + .../Unit/Helpers/Current_Page_Helper_Test.php | 41 ++++ 23 files changed, 1218 insertions(+), 20 deletions(-) create mode 100644 packages/js/tests/bulk-editor/overview-selection-notice.test.js create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php create mode 100644 tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php diff --git a/packages/js/tests/bulk-editor/bulk-editor-content.test.js b/packages/js/tests/bulk-editor/bulk-editor-content.test.js index 2bedd4877d2..76ad21a3fa4 100644 --- a/packages/js/tests/bulk-editor/bulk-editor-content.test.js +++ b/packages/js/tests/bulk-editor/bulk-editor-content.test.js @@ -1,7 +1,7 @@ import { Fill, SlotFillProvider } from "@wordpress/components"; import { dispatch } from "@wordpress/data"; import { act, fireEvent, render, screen, waitFor } from "../test-utils"; -import { BulkEditorContent, getSelectionView } from "../../src/bulk-editor/components/bulk-editor-content"; +import { BulkEditorContent, getSelectionView, shouldShowBulkActions } from "../../src/bulk-editor/components/bulk-editor-content"; import { FIELD_SET_SEARCH, PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../../src/bulk-editor/constants"; import { DataProvider } from "../../src/bulk-editor/services"; import registerStore from "../../src/bulk-editor/store"; @@ -120,6 +120,22 @@ describe( "getSelectionView", () => { expect( view.totalCount ).toBe( 42 ); } ); + it( "does not read a carried-over selection of other rows as all selected", () => { + // Two visible rows plus an id that is not on this page (carried over from the WP admin overview). + const view = getSelectionView( false, [ 1, 2, 99 ], items, 30 ); + + expect( view.isAllSelected ).toBe( false ); + expect( view.isIndeterminate ).toBe( true ); + expect( view.selectedCount ).toBe( 3 ); + } ); + + it( "reads a selection covering every visible row as all selected, even with extra off-page rows", () => { + const view = getSelectionView( false, [ 1, 2, 3, 99 ], items, 30 ); + + expect( view.isAllSelected ).toBe( true ); + expect( view.isIndeterminate ).toBe( false ); + } ); + it( "treats only editable rows as selectable", () => { const mixed = [ { id: 1, editable: true }, { id: 2, editable: false }, { id: 3, editable: true } ]; @@ -132,6 +148,25 @@ describe( "getSelectionView", () => { } ); } ); +describe( "shouldShowBulkActions", () => { + const closed = { hasSelection: false, isAiEnabled: false, hasUnsavedEdits: false, hasExternalPendingChanges: false, hasOverviewNotice: false }; + + it( "keeps the band closed when nothing occupies it", () => { + expect( shouldShowBulkActions( closed ) ).toBe( false ); + } ); + + it( "opens the band for a selection only while AI is enabled", () => { + expect( shouldShowBulkActions( { ...closed, hasSelection: true } ) ).toBe( false ); + expect( shouldShowBulkActions( { ...closed, hasSelection: true, isAiEnabled: true } ) ).toBe( true ); + } ); + + it( "opens the band for unsaved edits, external pending changes and the overview notice", () => { + expect( shouldShowBulkActions( { ...closed, hasUnsavedEdits: true } ) ).toBe( true ); + expect( shouldShowBulkActions( { ...closed, hasExternalPendingChanges: true } ) ).toBe( true ); + expect( shouldShowBulkActions( { ...closed, hasOverviewNotice: true } ) ).toBe( true ); + } ); +} ); + describe( "BulkEditorContent tab-switch guard", () => { it( "switches immediately when nothing guards the switch", () => { renderContent(); diff --git a/packages/js/tests/bulk-editor/bulk-editor-filters.test.js b/packages/js/tests/bulk-editor/bulk-editor-filters.test.js index 8bb7f210840..5f518ea1af0 100644 --- a/packages/js/tests/bulk-editor/bulk-editor-filters.test.js +++ b/packages/js/tests/bulk-editor/bulk-editor-filters.test.js @@ -6,12 +6,15 @@ import registerStore from "../../src/bulk-editor/store"; describe( "BulkEditorFilters", () => { beforeAll( () => { - registerStore(); + // Seeded with a carried-over overview selection, like initialize.js does, so the + // "Overview selection" filter is offered. + registerStore( { initialState: { query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true } } } ); } ); beforeEach( () => { - // Reset the filter so the tests stay order-independent. + // Reset the filters so the tests stay order-independent. dispatch( STORE_NAME ).setStatuses( [] ); + dispatch( STORE_NAME ).setOverviewFilterActive( false ); } ); it( "renders the Filters button without a count badge when nothing is applied", () => { @@ -42,6 +45,24 @@ describe( "BulkEditorFilters", () => { expect( screen.getByText( "1" ) ).toBeInTheDocument(); } ); + it( "offers the Overview selection filter while a carried-over selection exists", () => { + render( ); + + fireEvent.click( screen.getByRole( "button", { name: /Filters/ } ) ); + + expect( screen.getByRole( "checkbox", { name: "Overview selection" } ) ).toBeInTheDocument(); + } ); + + it( "activates the overview filter, updates the store and counts it in the badge", () => { + render( ); + + fireEvent.click( screen.getByRole( "button", { name: /Filters/ } ) ); + fireEvent.click( screen.getByRole( "checkbox", { name: "Overview selection" } ) ); + + expect( select( STORE_NAME ).selectIsOverviewFilterActive() ).toBe( true ); + expect( screen.getByText( "1" ) ).toBeInTheDocument(); + } ); + it( "returns focus to the trigger when the dialog is closed with Escape", () => { render( ); diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js index dc2621a62db..8aaabf50adb 100644 --- a/packages/js/tests/bulk-editor/initialize.test.js +++ b/packages/js/tests/bulk-editor/initialize.test.js @@ -82,13 +82,95 @@ describe( "bulk editor initialize", () => { } ); expect( mockRegisterStore ).toHaveBeenCalledWith( { - initialState: { linkParams: { foo: "bar" } }, + initialState: { + linkParams: { foo: "bar" }, + activeContentType: "", + selection: { selectedIds: [], preselectedTotal: 0 }, + query: { overviewIds: [], isOverviewFilterActive: false }, + }, } ); expect( mockFixScrolling ).toHaveBeenCalledTimes( 1 ); expect( mockCreateRoot ).toHaveBeenCalledWith( root ); expect( mockRender ).toHaveBeenCalledTimes( 1 ); } ); + test( "should seed the store with the selection carried over from the WP admin overview", () => { + const root = document.createElement( "div" ); + root.id = ROOT_ID; + document.body.appendChild( root ); + window.wpseoBulkEditorData.initialSelection = { contentType: "page", postIds: [ 5, 3 ], selectedCount: 25 }; + + jest.isolateModules( () => { + require( "../../src/bulk-editor/initialize" ); + } ); + + expect( mockRegisterStore ).toHaveBeenCalledWith( { + initialState: { + linkParams: { foo: "bar" }, + activeContentType: "page", + selection: { selectedIds: [ 5, 3 ], preselectedTotal: 25 }, + query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, + }, + } ); + } ); + + describe( "getPreselectionState", () => { + /** + * Requires the module in isolation and returns the getPreselectionState export. + * + * @returns {Function} The getPreselectionState function. + */ + const requireGetPreselectionState = () => { + let getPreselectionState; + jest.isolateModules( () => { + ( { getPreselectionState } = require( "../../src/bulk-editor/initialize" ) ); + } ); + return getPreselectionState; + }; + + test( "should return an empty seed for a missing or malformed payload", () => { + const getPreselectionState = requireGetPreselectionState(); + + const emptySeed = { + activeContentType: "", + selection: { selectedIds: [], preselectedTotal: 0 }, + query: { overviewIds: [], isOverviewFilterActive: false }, + }; + expect( getPreselectionState() ).toEqual( emptySeed ); + expect( getPreselectionState( { contentType: 7, postIds: "5,3", selectedCount: 25 } ) ).toEqual( emptySeed ); + } ); + + test( "should drop non-numeric and non-positive ids and never let the total undercut them", () => { + const getPreselectionState = requireGetPreselectionState(); + + expect( getPreselectionState( { contentType: "post", postIds: [ "5", 3, 0, -2, "junk" ], selectedCount: 1 } ) ).toEqual( { + activeContentType: "post", + selection: { selectedIds: [ 5, 3 ], preselectedTotal: 2 }, + query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, + } ); + } ); + + test( "should cap the seeded ids at the batch size", () => { + const getPreselectionState = requireGetPreselectionState(); + + const postIds = Array.from( { length: 25 }, ( _, index ) => index + 1 ); + const { selection } = getPreselectionState( { contentType: "post", postIds, selectedCount: 25 } ); + + expect( selection.selectedIds ).toHaveLength( 20 ); + expect( selection.preselectedTotal ).toBe( 25 ); + } ); + + test( "should not report a carried-over total without any usable ids", () => { + const getPreselectionState = requireGetPreselectionState(); + + expect( getPreselectionState( { contentType: "post", postIds: [], selectedCount: 25 } ) ).toEqual( { + activeContentType: "post", + selection: { selectedIds: [], preselectedTotal: 0 }, + query: { overviewIds: [], isOverviewFilterActive: false }, + } ); + } ); + } ); + test( "should construct the remote data provider with the REST nonce header", () => { const root = document.createElement( "div" ); root.id = ROOT_ID; diff --git a/packages/js/tests/bulk-editor/overview-selection-notice.test.js b/packages/js/tests/bulk-editor/overview-selection-notice.test.js new file mode 100644 index 00000000000..6a472ae6bce --- /dev/null +++ b/packages/js/tests/bulk-editor/overview-selection-notice.test.js @@ -0,0 +1,92 @@ +import { SlotFillProvider } from "@wordpress/components"; +import { OverviewSelectionNotice } from "../../src/bulk-editor/components/overview-selection-notice"; +import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content"; +import { DataProvider } from "../../src/bulk-editor/services"; +import registerStore from "../../src/bulk-editor/store"; +import { fireEvent, render, screen } from "../test-utils"; + +const NOTICE_TEXT = "Only the first 20 posts from your selection were carried over. The bulk editor supports up to 20 posts at a time."; + +describe( "OverviewSelectionNotice", () => { + it( "explains that only the first batch of the overview selection is selected, naming the content type", () => { + render( ); + + expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); + } ); + + it( "falls back to a generic noun without a content type label", () => { + render( ); + + expect( screen.getByText( + "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time." + ) ).toBeInTheDocument(); + } ); + + it( "calls onDismiss when the dismiss button is clicked", () => { + const onDismiss = jest.fn(); + render( ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + expect( onDismiss ).toHaveBeenCalledTimes( 1 ); + } ); + + it( "renders nothing while the whole selection fits the batch", () => { + const { container } = render( ); + + expect( container ).toBeEmptyDOMElement(); + } ); +} ); + +describe( "BulkEditorContent with a carried-over overview selection", () => { + const dataProvider = new DataProvider( { + contentTypes: [ { name: "post", label: "Posts", singularLabel: "Post" } ], + endpoints: { posts: "https://example.com/wp-json/yoast/v1/bulk_editor/posts" }, + links: {}, + } ); + // The data layer is covered by use-posts.test.js; the request stays pending here. + const remoteDataProvider = { fetchJson: jest.fn( () => new Promise( () => {} ) ) }; + + // The store registers once for the whole file: the WP data registry is global, so a second register would + // clash. Seeded like initialize.js does for a selection carried over from the WP admin overview. + beforeAll( () => { + registerStore( { + initialState: { + activeContentType: "post", + selection: { selectedIds: Array.from( { length: 20 }, ( _, index ) => index + 1 ), preselectedTotal: 25 }, + }, + } ); + } ); + + const renderContent = () => render( + + + + ); + + it( "shows the notice inside the expanded bulk-actions row and hides it on dismiss", () => { + const { container } = renderContent(); + + const notice = screen.getByText( NOTICE_TEXT ); + expect( notice ).toBeInTheDocument(); + // The notice renders inside the table's bulk-actions row (between the Select toolbar and the band), + // which the notice itself keeps expanded. + const bandRow = notice.closest( "tr" ); + expect( bandRow ).not.toBeNull(); + expect( bandRow ).toHaveAttribute( "aria-hidden", "false" ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + expect( screen.queryByText( NOTICE_TEXT ) ).not.toBeInTheDocument(); + // Nothing else occupies the band here (AI is disabled, no edits), so dismissing collapses the row. + container.querySelectorAll( "tr[aria-hidden]" ).forEach( ( row ) => { + expect( row ).toHaveAttribute( "aria-hidden", "true" ); + } ); + } ); +} ); diff --git a/packages/js/tests/bulk-editor/services/use-posts.test.js b/packages/js/tests/bulk-editor/services/use-posts.test.js index a343f87d50b..4d686151756 100644 --- a/packages/js/tests/bulk-editor/services/use-posts.test.js +++ b/packages/js/tests/bulk-editor/services/use-posts.test.js @@ -11,12 +11,14 @@ describe( "usePosts", () => { beforeEach( () => { dataProvider = { getEndpoint: jest.fn( () => "https://example.com/wp-json/yoast/v1/bulk_editor/posts" ) }; - storeState = { search: "", page: 1, statuses: [] }; + storeState = { search: "", page: 1, statuses: [], overviewIds: [], isOverviewFilterActive: false }; // Resolve each useSelect call against our controllable store state. useSelect.mockImplementation( ( mapSelect ) => mapSelect( () => ( { selectSearch: () => storeState.search, selectPage: () => storeState.page, selectStatuses: () => storeState.statuses, + selectOverviewIds: () => storeState.overviewIds, + selectIsOverviewFilterActive: () => storeState.isOverviewFilterActive, } ) ) ); } ); @@ -37,6 +39,36 @@ describe( "usePosts", () => { ); } ); + it( "requests only the carried-over posts while the overview filter is active", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: true }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [] } ) ) }; + + renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( remoteDataProvider.fetchJson ).toHaveBeenCalled() ); + + expect( remoteDataProvider.fetchJson ).toHaveBeenCalledWith( + "https://example.com/wp-json/yoast/v1/bulk_editor/posts", + expect.objectContaining( { include: [ "5", "3" ] } ), + expect.objectContaining( { signal: expect.anything() } ) + ); + } ); + + it( "does not send the carried-over posts while the overview filter is inactive", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: false }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [] } ) ) }; + + renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( remoteDataProvider.fetchJson ).toHaveBeenCalled() ); + + expect( remoteDataProvider.fetchJson ).toHaveBeenCalledWith( + "https://example.com/wp-json/yoast/v1/bulk_editor/posts", + expect.not.objectContaining( { include: expect.anything() } ), + expect.objectContaining( { signal: expect.anything() } ) + ); + } ); + it( "maps the snake_case API rows to camelCase bulk editor rows and exposes the totals", async() => { const remoteDataProvider = { /* eslint-disable camelcase -- The REST endpoint returns snake_case fields. */ diff --git a/packages/js/tests/bulk-editor/store/query.test.js b/packages/js/tests/bulk-editor/store/query.test.js index 59ccc5bf9c0..451a20ef0d9 100644 --- a/packages/js/tests/bulk-editor/store/query.test.js +++ b/packages/js/tests/bulk-editor/store/query.test.js @@ -2,8 +2,8 @@ import { activeContentTypeActions } from "../../../src/bulk-editor/store/active- import reducer, { createInitialQueryState, queryActions, querySelectors } from "../../../src/bulk-editor/store/query"; describe( "query slice", () => { - it( "defaults to an empty search on the first page with no status filter", () => { - expect( createInitialQueryState() ).toEqual( { search: "", page: 1, statuses: [] } ); + it( "defaults to an empty search on the first page with no status filter and no overview selection", () => { + expect( createInitialQueryState() ).toEqual( { search: "", page: 1, statuses: [], overviewIds: [], isOverviewFilterActive: false } ); } ); it( "sets the search term and resets to the first page", () => { @@ -24,10 +24,30 @@ describe( "query slice", () => { expect( state ).toEqual( { search: "seo", page: 1, statuses: [ "draft", "pending" ] } ); } ); - it( "resets to the first page when the content type changes", () => { - const state = reducer( { search: "seo", page: 9 }, activeContentTypeActions.setActiveContentType( "page" ) ); + it( "resets to the first page and drops the overview selection when the content type changes", () => { + const state = reducer( + { search: "seo", page: 9, overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, + activeContentTypeActions.setActiveContentType( "page" ) + ); - expect( state ).toEqual( { search: "seo", page: 1 } ); + expect( state ).toEqual( { search: "seo", page: 1, overviewIds: [], isOverviewFilterActive: false } ); + } ); + + it( "toggles the overview filter and resets to the first page", () => { + let state = reducer( { page: 4, overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, queryActions.setOverviewFilterActive( false ) ); + expect( state ).toEqual( { page: 1, overviewIds: [ 5, 3 ], isOverviewFilterActive: false } ); + + state = reducer( { ...state, page: 2 }, queryActions.setOverviewFilterActive( true ) ); + expect( state ).toEqual( { page: 1, overviewIds: [ 5, 3 ], isOverviewFilterActive: true } ); + } ); + + it( "selects the overview selection state, defaulting when missing", () => { + const state = { query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true } }; + + expect( querySelectors.selectOverviewIds( state ) ).toEqual( [ 5, 3 ] ); + expect( querySelectors.selectIsOverviewFilterActive( state ) ).toBe( true ); + expect( querySelectors.selectOverviewIds( {} ) ).toEqual( [] ); + expect( querySelectors.selectIsOverviewFilterActive( {} ) ).toBe( false ); } ); it( "selects the search term, page, statuses and query from the store state", () => { @@ -43,6 +63,6 @@ describe( "query slice", () => { expect( querySelectors.selectSearch( {} ) ).toBe( "" ); expect( querySelectors.selectPage( {} ) ).toBe( 1 ); expect( querySelectors.selectStatuses( {} ) ).toEqual( [] ); - expect( querySelectors.selectQuery( {} ) ).toEqual( { search: "", page: 1, statuses: [] } ); + expect( querySelectors.selectQuery( {} ) ).toEqual( { search: "", page: 1, statuses: [], overviewIds: [], isOverviewFilterActive: false } ); } ); } ); diff --git a/packages/js/tests/bulk-editor/store/selection.test.js b/packages/js/tests/bulk-editor/store/selection.test.js index e39c6869ce5..a4916424aa5 100644 --- a/packages/js/tests/bulk-editor/store/selection.test.js +++ b/packages/js/tests/bulk-editor/store/selection.test.js @@ -3,8 +3,8 @@ import reducer, { createInitialSelectionState, selectionActions, selectionSelect import { queryActions } from "../../../src/bulk-editor/store/query"; describe( "selection slice", () => { - it( "defaults to no rows selected", () => { - expect( createInitialSelectionState() ).toEqual( { selectedIds: [] } ); + it( "defaults to no rows selected and no carried-over selection", () => { + expect( createInitialSelectionState() ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); } ); it( "adds a row to the selection when toggled on", () => { @@ -66,4 +66,45 @@ describe( "selection slice", () => { expect( selectionSelectors.selectSelectedIds( { selection: { selectedIds: [ 7 ] } } ) ).toEqual( [ 7 ] ); expect( selectionSelectors.selectSelectedIds( {} ) ).toEqual( [] ); } ); + + it( "clears the carried-over selection total on dismissPreselectionNotice", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, selectionActions.dismissPreselectionNotice() ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0 } ); + } ); + + it( "clears the carried-over selection total on selectAll", () => { + const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.selectAll( [ 7, 9 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0 } ); + } ); + + it( "clears the carried-over selection total on deselectAll", () => { + const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.deselectAll() ); + + expect( state ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + } ); + + it( "keeps the carried-over selection total when a single row is toggled", () => { + const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.toggleRow( 9 ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25 } ); + } ); + + it( "clears the carried-over selection total when the shown result set changes", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setPage( 2 ) ); + + expect( state ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + } ); + + it( "clears the selection when the overview filter is toggled", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setOverviewFilterActive( false ) ); + + expect( state ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + } ); + + it( "selects the carried-over selection total, defaulting to 0 when missing", () => { + expect( selectionSelectors.selectPreselectedTotal( { selection: { preselectedTotal: 25 } } ) ).toBe( 25 ); + expect( selectionSelectors.selectPreselectedTotal( {} ) ).toBe( 0 ); + } ); } ); diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php index 806ff2da585..3d2a4c1ff31 100644 --- a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php +++ b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php @@ -78,4 +78,32 @@ public function test_has_author_filter() { $this->assertTrue( ( new Posts_Query( 'page', 1, 20, '', [], 5 ) )->has_author_filter() ); $this->assertFalse( ( new Posts_Query( 'page', 1, 20, '', [] ) )->has_author_filter() ); } + + /** + * Tests that the included post IDs default to no restriction. + * + * @return void + */ + public function test_get_include_defaults_to_empty() { + $this->assertSame( [], ( new Posts_Query( 'page', 1, 20, '', [] ) )->get_include() ); + } + + /** + * Tests that the included post IDs are carried through when set. + * + * @return void + */ + public function test_get_include() { + $this->assertSame( [ 5, 3 ], ( new Posts_Query( 'page', 1, 20, '', [], null, [ 5, 3 ] ) )->get_include() ); + } + + /** + * Tests that has_include reflects whether a post ID restriction is set. + * + * @return void + */ + public function test_has_include() { + $this->assertTrue( ( new Posts_Query( 'page', 1, 20, '', [], null, [ 5, 3 ] ) )->has_include() ); + $this->assertFalse( ( new Posts_Query( 'page', 1, 20, '', [] ) )->has_include() ); + } } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index 9d52fb1faa5..fefcb29a9dc 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -238,6 +238,39 @@ public function test_get_posts_with_search() { $this->assertSame( 0, $result['total'] ); } + /** + * Tests that a post ID restriction narrows the query to those posts. + * + * @return void + */ + public function test_get_posts_restricts_to_the_included_post_ids() { + $indexable = new Indexable_Mock(); + $indexable->object_id = 5; + + $query = Mockery::mock( ORM::class ); + $query->allows( 'where' )->andReturnSelf(); + $query->expects( 'where_in' )->with( 'post_status', self::STATUSES )->andReturnSelf(); + $query->expects( 'where_in' )->with( 'object_id', [ 5, 3 ] )->andReturnSelf(); + $query->allows( 'order_by_desc' )->andReturnSelf(); + $query->allows( 'limit' )->andReturnSelf(); + $query->allows( 'offset' )->andReturnSelf(); + // The page is not full, so the total is derived and build_query runs only once. + $query->expects( 'find_many' )->once()->andReturn( [ $indexable ] ); + $query->expects( 'count' )->never(); + + $this->indexable_repository->allows( 'query' )->andReturn( $query ); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 5 ] )->andReturn( [ 5 => true ] ); + + Functions\expect( 'get_the_title' )->once()->with( 5 )->andReturn( 'Hello world' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 5, 'raw' )->andReturn( 'edit' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [ 5, 3 ] ) )->to_array(); + + $this->assertSame( 5, $result['posts'][0]['id'] ); + $this->assertSame( 1, $result['total'] ); + } + /** * Stubs the indexable query for a page that returns the given rows, without constraining count(). * diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php new file mode 100644 index 00000000000..2dd3830b9c3 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php @@ -0,0 +1,33 @@ +assertSame( + [ + 'settings-updated', + Bulk_Editor_Integration::POST_IDS_PARAM, + Bulk_Editor_Integration::SELECTED_COUNT_PARAM, + ], + $this->instance->add_removable_query_args( [ 'settings-updated' ] ), + ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php index aa5ee5052a2..94e37209a95 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,28 @@ 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' ], + 'initialSelection' => [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ], ]; Actions\expectRemoved( 'admin_print_scripts' )->once()->with( 'print_emoji_detection_script' ); diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php new file mode 100644 index 00000000000..f29be6c0f92 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php @@ -0,0 +1,193 @@ +assertSame( + [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that a supported content type is carried over, also without post IDs. + * + * @return void + */ + public function test_carries_a_supported_content_type() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [], + 'selectedCount' => 0, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that an unsupported content type drops the whole selection. + * + * @return void + */ + public function test_ignores_an_unsupported_content_type() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'unsupported'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3'; + + $this->assertSame( + [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that duplicate, non-numeric, zero and negative post IDs are dropped. + * + * @return void + */ + public function test_sanitizes_the_post_ids() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '5,3,3,0,-2,junk'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [ 5, 3 ], + 'selectedCount' => 2, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that the post IDs are capped at the batch limit while the count keeps the overview's total. + * + * @return void + */ + public function test_caps_the_post_ids_at_the_batch_limit() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = \implode( ',', \range( 1, 25 ) ); + $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = '30'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => \range( 1, 20 ), + 'selectedCount' => 30, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that the selected count can never be lower than the number of carried post IDs. + * + * @return void + */ + public function test_selected_count_never_undercuts_the_post_ids() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3'; + $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = '1'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [ 1, 2, 3 ], + 'selectedCount' => 3, + ], + $this->get_initial_selection(), + ); + } + + /** + * Primes every collaborator the script data needs and returns the initial selection part. + * + * @return array> The initial selection script data. + */ + private function get_initial_selection() { + $this->stubEscapeFunctions(); + Functions\stubs( + [ + 'rest_url' => 'https://example.com/wp-json/', + 'is_rtl' => false, + 'plugins_url' => 'https://example.com/wp-content/plugins/wordpress-seo', + 'admin_url' => static function ( $path ) { + return 'https://example.com/wp-admin/' . $path; + }, + 'wp_unslash' => static function ( $value ) { + return $value; + }, + 'sanitize_text_field' => static function ( $value ) { + return $value; + }, + ], + ); + + $endpoint_list = Mockery::mock( Endpoint_List::class ); + $endpoint_list->allows( 'to_array' )->andReturn( [] ); + + $this->content_types_repository->allows( 'get_content_types' )->andReturn( + [ + [ + 'name' => 'post', + 'label' => 'Posts', + 'singularLabel' => 'Post', + ], + ], + ); + $this->endpoints_repository->allows( 'get_all_endpoints' )->andReturn( $endpoint_list ); + $this->nonce_repository->allows( 'get_rest_nonce' )->andReturn( 'rest-nonce' ); + $this->product_helper->allows( 'is_premium' )->andReturn( false ); + $this->options_helper->allows( 'get' )->andReturn( true ); + $this->short_link_helper->allows( 'get_query_params' )->andReturn( [] ); + + return $this->instance->get_script_data()['initialSelection']; + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php index 9ef1633a884..e0799e5df8b 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php @@ -33,6 +33,7 @@ public function test_register_hooks_not_on_bulk_editor_page() { Actions\expectAdded( 'admin_head' )->once()->with( [ $this->instance, 'remove_menu_item' ] ); Actions\expectAdded( 'admin_enqueue_scripts' )->never(); Actions\expectAdded( 'in_admin_header' )->never(); + Filters\expectAdded( 'removable_query_args' )->never(); $this->instance->register_hooks(); } @@ -53,6 +54,7 @@ public function test_register_hooks_on_bulk_editor_page() { Actions\expectAdded( 'admin_head' )->once()->with( [ $this->instance, 'remove_menu_item' ] ); Actions\expectAdded( 'admin_enqueue_scripts' )->once()->with( [ $this->instance, 'enqueue_assets' ] ); Actions\expectAdded( 'in_admin_header' )->once()->with( [ $this->instance, 'remove_notices' ], \PHP_INT_MAX ); + Filters\expectAdded( 'removable_query_args' )->once()->with( [ $this->instance, 'add_removable_query_args' ] ); $this->instance->register_hooks(); } diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php new file mode 100644 index 00000000000..bd931a2339f --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php @@ -0,0 +1,94 @@ +content_types_repository = Mockery::mock( Content_Types_Repository::class ); + $this->current_page_helper = Mockery::mock( Current_Page_Helper::class ); + + $this->instance = new Posts_Overview_Bulk_Actions_Integration( + $this->content_types_repository, + $this->current_page_helper, + ); + } + + /** + * Creates a WP_Screen mock for a post overview screen. + * + * @param string $post_type The screen post type. + * + * @return Mockery\MockInterface|WP_Screen The screen mock. + */ + protected function mock_screen( $post_type = 'post' ) { + $screen = Mockery::mock( 'WP_Screen' ); + $screen->post_type = $post_type; + + return $screen; + } + + /** + * Creates the content types repository representation of the given post types. + * + * @param array $post_types The post type names. + * + * @return array> The content types. + */ + protected function content_types_for( array $post_types ) { + $content_types = []; + foreach ( $post_types as $post_type ) { + $content_types[] = [ + 'name' => $post_type, + 'label' => \ucfirst( $post_type ) . 's', + 'singularLabel' => \ucfirst( $post_type ), + ]; + } + + return $content_types; + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php new file mode 100644 index 00000000000..2466133ac5e --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php @@ -0,0 +1,73 @@ +stubTranslationFunctions(); + Functions\when( 'is_rtl' )->justReturn( false ); + $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( false ); + + $actions = [ 'edit' => 'Edit' ]; + + $this->assertSame( + [ + 'edit' => 'Edit', + 'Yoast SEO' => [ + Posts_Overview_Bulk_Actions_Integration::BULK_ACTION => "Bulk edit \u{2197}\u{FE0E}", + ], + ], + $this->instance->add_bulk_action( $actions ), + ); + } + + /** + * Tests that the navigation arrow is mirrored on RTL admins. + * + * @return void + */ + public function test_mirrors_the_arrow_on_rtl() { + $this->stubTranslationFunctions(); + Functions\when( 'is_rtl' )->justReturn( true ); + $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( false ); + + $actions = $this->instance->add_bulk_action( [] ); + + $this->assertSame( + "Bulk edit \u{2196}\u{FE0E}", + $actions['Yoast SEO'][ Posts_Overview_Bulk_Actions_Integration::BULK_ACTION ], + ); + } + + /** + * Tests that the entry is not added on the trash view. + * + * @return void + */ + public function test_does_not_add_the_bulk_action_on_the_trash_view() { + $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( true ); + + $actions = [ 'untrash' => 'Restore' ]; + + $this->assertSame( $actions, $this->instance->add_bulk_action( $actions ) ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php new file mode 100644 index 00000000000..8f51757e5c6 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php @@ -0,0 +1,34 @@ +assertInstanceOf( + Content_Types_Repository::class, + $this->getPropertyValue( $this->instance, 'content_types_repository' ), + ); + $this->assertInstanceOf( + Current_Page_Helper::class, + $this->getPropertyValue( $this->instance, 'current_page_helper' ), + ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php new file mode 100644 index 00000000000..ec7579d1f4d --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php @@ -0,0 +1,27 @@ +assertSame( [ Admin_Conditional::class ], Posts_Overview_Bulk_Actions_Integration::get_conditionals() ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php new file mode 100644 index 00000000000..46e0516deda --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php @@ -0,0 +1,140 @@ +assertSame( + 'https://example.com/wp-admin/edit.php', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', 'trash', [ 1, 2 ] ), + ); + } + + /** + * Tests that without a current screen the redirect URL is left untouched. + * + * @return void + */ + public function test_leaves_the_redirect_alone_without_a_screen() { + Functions\expect( 'get_current_screen' )->once()->andReturn( null ); + + $this->assertSame( + 'https://example.com/wp-admin/edit.php', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, [ 1 ] ), + ); + } + + /** + * Tests that the action redirects to the bulk editor page with the selection carried over. + * + * @return void + */ + public function test_redirects_to_the_bulk_editor_page_with_the_selection() { + Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'post' ) ); + + Functions\expect( 'admin_url' ) + ->once() + ->with( 'admin.php' ) + ->andReturn( 'https://example.com/wp-admin/admin.php' ); + Functions\expect( 'add_query_arg' ) + ->once() + ->with( + [ + 'page' => 'wpseo_page_bulk_edit', + 'content_type' => 'post', + 'post_ids' => '5,3', + 'selected_count' => 2, + ], + 'https://example.com/wp-admin/admin.php', + ) + ->andReturn( 'the-bulk-editor-url' ); + + // Duplicate, non-numeric, zero and negative IDs are dropped. + $post_ids = [ '5', 3, 3, 0, -2, 'junk' ]; + + $this->assertSame( + 'the-bulk-editor-url', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, $post_ids ), + ); + } + + /** + * Tests that only the first batch of IDs is carried while the count reflects the whole selection. + * + * @return void + */ + public function test_truncates_the_carried_ids_to_the_batch_limit() { + Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'page' ) ); + + $post_ids = \range( 1, 25 ); + + Functions\expect( 'admin_url' ) + ->once() + ->with( 'admin.php' ) + ->andReturn( 'https://example.com/wp-admin/admin.php' ); + Functions\expect( 'add_query_arg' ) + ->once() + ->with( + [ + 'page' => 'wpseo_page_bulk_edit', + 'content_type' => 'page', + 'post_ids' => \implode( ',', \range( 1, 20 ) ), + 'selected_count' => 25, + ], + 'https://example.com/wp-admin/admin.php', + ) + ->andReturn( 'the-bulk-editor-url' ); + + $this->assertSame( + 'the-bulk-editor-url', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, $post_ids ), + ); + } + + /** + * Tests that without any usable IDs the redirect only carries the content type. + * + * @return void + */ + public function test_redirects_without_ids_when_none_are_usable() { + Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'post' ) ); + + Functions\expect( 'admin_url' ) + ->once() + ->with( 'admin.php' ) + ->andReturn( 'https://example.com/wp-admin/admin.php' ); + Functions\expect( 'add_query_arg' ) + ->once() + ->with( + [ + 'page' => 'wpseo_page_bulk_edit', + 'content_type' => 'post', + ], + 'https://example.com/wp-admin/admin.php', + ) + ->andReturn( 'the-bulk-editor-url' ); + + $this->assertSame( + 'the-bulk-editor-url', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, [ 0, 'junk' ] ), + ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php new file mode 100644 index 00000000000..e8de9fd61a1 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php @@ -0,0 +1,81 @@ +once() + ->with( 'wpseo_manage_options' ) + ->andReturn( false ); + + $this->content_types_repository->expects( 'get_content_types' )->never(); + + Filters\expectAdded( 'bulk_actions-edit-post' )->never(); + Filters\expectAdded( 'handle_bulk_actions-edit-post' )->never(); + + $this->instance->register_bulk_actions(); + } + + /** + * Tests that the bulk action filters are registered for every supported post type. + * + * @return void + */ + public function test_registers_the_bulk_action_filters_per_supported_post_type() { + Functions\expect( 'current_user_can' ) + ->once() + ->with( 'wpseo_manage_options' ) + ->andReturn( true ); + + $this->content_types_repository->expects( 'get_content_types' ) + ->once() + ->andReturn( $this->content_types_for( [ 'post', 'page' ] ) ); + + Filters\expectAdded( 'bulk_actions-edit-post' )->once()->with( [ $this->instance, 'add_bulk_action' ] ); + Filters\expectAdded( 'handle_bulk_actions-edit-post' )->once()->with( [ $this->instance, 'handle_bulk_action' ], 10, 3 ); + Filters\expectAdded( 'bulk_actions-edit-page' )->once()->with( [ $this->instance, 'add_bulk_action' ] ); + Filters\expectAdded( 'handle_bulk_actions-edit-page' )->once()->with( [ $this->instance, 'handle_bulk_action' ], 10, 3 ); + + $this->instance->register_bulk_actions(); + } + + /** + * Tests that without supported post types no filters are registered. + * + * @return void + */ + public function test_registers_nothing_without_supported_post_types() { + Functions\expect( 'current_user_can' ) + ->once() + ->with( 'wpseo_manage_options' ) + ->andReturn( true ); + + $this->content_types_repository->expects( 'get_content_types' ) + ->once() + ->andReturn( [] ); + + Filters\expectAdded( 'bulk_actions-edit-post' )->never(); + Filters\expectAdded( 'handle_bulk_actions-edit-post' )->never(); + + $this->instance->register_bulk_actions(); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php new file mode 100644 index 00000000000..0f4820903b4 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php @@ -0,0 +1,28 @@ +once()->with( [ $this->instance, 'register_bulk_actions' ] ); + + $this->instance->register_hooks(); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php index 39fcaea2796..be24193a504 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php @@ -36,6 +36,7 @@ public function test_get_posts_without_author_restriction() { $request->expects( 'get_param' )->with( 'per_page' )->andReturn( 20 ); $request->expects( 'get_param' )->with( 'search' )->andReturn( 'seo' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'draft', 'pending' ] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->content_types_repository ->expects( 'get_content_types' ) @@ -86,6 +87,7 @@ public function test_get_posts_restricts_to_own_posts_when_user_cannot_edit_othe $request->expects( 'get_param' )->with( 'per_page' )->andReturn( 20 ); $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->content_types_repository ->expects( 'get_content_types' ) @@ -135,6 +137,7 @@ public function test_get_posts_falls_back_to_all_statuses_when_none_selected() { $request->expects( 'get_param' )->with( 'per_page' )->andReturn( 20 ); $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->content_types_repository ->expects( 'get_content_types' ) @@ -171,6 +174,55 @@ static function ( $query ) { $this->assertInstanceOf( WP_REST_Response::class, $this->instance->get_posts( $request ) ); } + /** + * Tests that the included post IDs are deduplicated and carried into the query. + * + * @return void + */ + public function test_get_posts_carries_the_included_post_ids() { + $request = Mockery::mock( WP_REST_Request::class ); + $request->expects( 'get_param' )->with( 'content_type' )->andReturn( 'page' ); + $request->expects( 'get_param' )->with( 'page' )->andReturn( 1 ); + $request->expects( 'get_param' )->with( 'per_page' )->andReturn( 20 ); + $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); + $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [ 5, '3', 3, 5 ] ); + + $this->content_types_repository + ->expects( 'get_content_types' ) + ->once() + ->andReturn( + [ + [ + 'name' => 'page', + 'label' => 'Pages', + ], + ], + ); + + $this->content_type_access_checker->expects( 'can_edit_others' )->with( 'page' )->andReturnTrue(); + + $posts_page = Mockery::mock( Posts_Page::class ); + $posts_page->expects( 'to_array' )->once()->andReturn( [] ); + + $this->posts_repository + ->expects( 'get_posts' ) + ->once() + ->with( + Mockery::on( + static function ( $query ) { + return $query instanceof Posts_Query + && $query->get_include() === [ 5, 3 ]; + }, + ), + ) + ->andReturn( $posts_page ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->get_posts( $request ) ); + } + /** * Tests that an unknown content type returns a WP_Error. * diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php index 8896552508a..e920038e65d 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php @@ -73,6 +73,17 @@ public function test_register_routes() { ], 'description' => 'The post statuses to include.', ], + 'include' => [ + 'required' => false, + 'type' => 'array', + 'default' => [], + 'maxItems' => 100, + 'items' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.', + ], ], 'callback' => [ $this->instance, 'get_posts' ], 'permission_callback' => [ $this->instance, 'check_permissions' ], diff --git a/tests/Unit/Helpers/Current_Page_Helper_Test.php b/tests/Unit/Helpers/Current_Page_Helper_Test.php index 0b4315c119f..578fcdb99f5 100644 --- a/tests/Unit/Helpers/Current_Page_Helper_Test.php +++ b/tests/Unit/Helpers/Current_Page_Helper_Test.php @@ -894,4 +894,45 @@ public function test_get_current_yoast_seo_page_page_is_int() { $this->assertEquals( null, $this->instance->get_current_yoast_seo_page() ); } + + /** + * Test is_trash_overview function when the overview shows the trash. + * + * @covers ::is_trash_overview + * + * @return void + */ + public function test_is_trash_overview() { + $_REQUEST['post_status'] = 'trash'; + + $this->assertTrue( $this->instance->is_trash_overview() ); + + unset( $_REQUEST['post_status'] ); + } + + /** + * Test is_trash_overview function when the overview shows another status. + * + * @covers ::is_trash_overview + * + * @return void + */ + public function test_is_trash_overview_other_status() { + $_REQUEST['post_status'] = 'draft'; + + $this->assertFalse( $this->instance->is_trash_overview() ); + + unset( $_REQUEST['post_status'] ); + } + + /** + * Test is_trash_overview function when no status is requested. + * + * @covers ::is_trash_overview + * + * @return void + */ + public function test_is_trash_overview_without_status() { + $this->assertFalse( $this->instance->is_trash_overview() ); + } } From 29b8a2b6ebe26422e1839051d7f994a67ddb5edf Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Thu, 23 Jul 2026 09:56:48 +0200 Subject: [PATCH 17/31] Fix cs --- src/bulk-editor/domain/posts/posts-query.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bulk-editor/domain/posts/posts-query.php b/src/bulk-editor/domain/posts/posts-query.php index a3100f8dd1f..ebbd01b7336 100644 --- a/src/bulk-editor/domain/posts/posts-query.php +++ b/src/bulk-editor/domain/posts/posts-query.php @@ -69,7 +69,7 @@ class Posts_Query { * @param string $search The search term, or an empty string for no search. * @param array $statuses The post statuses to include. * @param int|null $author_id The author to limit posts to, or null for no author restriction. - * @param array $include The post IDs to limit posts to, or an empty array for no restriction. + * @param array $include_ids The post IDs to limit posts to, or an empty array for no restriction. */ public function __construct( string $content_type, @@ -78,7 +78,7 @@ public function __construct( string $search, array $statuses, ?int $author_id = null, - array $include = [] + array $include_ids = [] ) { $this->content_type = $content_type; $this->page = $page; @@ -86,7 +86,7 @@ public function __construct( $this->search = $search; $this->statuses = $statuses; $this->author_id = $author_id; - $this->include = $include; + $this->include = $include_ids; } /** From 5295edaaa777be1982e9371eb5a05d43016f3828 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Thu, 23 Jul 2026 10:18:37 +0200 Subject: [PATCH 18/31] Fix cs --- src/bulk-editor/domain/posts/posts-query.php | 10 +++++----- .../infrastructure/posts/indexable-posts-collector.php | 2 +- .../infrastructure/posts/post-meta-posts-collector.php | 2 +- .../Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php | 4 ++-- .../User_Interface/Posts_Route/Get_Posts_Test.php | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/bulk-editor/domain/posts/posts-query.php b/src/bulk-editor/domain/posts/posts-query.php index ebbd01b7336..01289b364e3 100644 --- a/src/bulk-editor/domain/posts/posts-query.php +++ b/src/bulk-editor/domain/posts/posts-query.php @@ -58,7 +58,7 @@ class Posts_Query { * * @var array */ - private $include; + private $include_ids; /** * The constructor. @@ -86,7 +86,7 @@ public function __construct( $this->search = $search; $this->statuses = $statuses; $this->author_id = $author_id; - $this->include = $include_ids; + $this->include_ids = $include_ids; } /** @@ -166,8 +166,8 @@ public function has_author_filter(): bool { * * @return array The post IDs, or an empty array for no restriction. */ - public function get_include(): array { - return $this->include; + public function get_include_ids(): array { + return $this->include_ids; } /** @@ -176,7 +176,7 @@ public function get_include(): array { * @return bool Whether a post ID restriction is set. */ public function has_include(): bool { - return $this->include !== []; + return $this->include_ids !== []; } /** diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index e2b602d77ec..9b2929bd474 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -130,7 +130,7 @@ private function build_query( Posts_Query $query ): ORM { } if ( $query->has_include() ) { - $builder->where_in( 'object_id', $query->get_include() ); + $builder->where_in( 'object_id', $query->get_include_ids() ); } if ( $query->has_search() ) { diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index 6d03c097acc..130501085e0 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -136,7 +136,7 @@ private function build_query_args( Posts_Query $query ): array { } if ( $query->has_include() ) { - $args['post__in'] = $query->get_include(); + $args['post__in'] = $query->get_include_ids(); } return $args; diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php index 3d2a4c1ff31..6479deb6415 100644 --- a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php +++ b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php @@ -85,7 +85,7 @@ public function test_has_author_filter() { * @return void */ public function test_get_include_defaults_to_empty() { - $this->assertSame( [], ( new Posts_Query( 'page', 1, 20, '', [] ) )->get_include() ); + $this->assertSame( [], ( new Posts_Query( 'page', 1, 20, '', [] ) )->get_include_ids() ); } /** @@ -94,7 +94,7 @@ public function test_get_include_defaults_to_empty() { * @return void */ public function test_get_include() { - $this->assertSame( [ 5, 3 ], ( new Posts_Query( 'page', 1, 20, '', [], null, [ 5, 3 ] ) )->get_include() ); + $this->assertSame( [ 5, 3 ], ( new Posts_Query( 'page', 1, 20, '', [], null, [ 5, 3 ] ) )->get_include_ids() ); } /** diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php index be24193a504..f50c721f8ee 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php @@ -212,7 +212,7 @@ public function test_get_posts_carries_the_included_post_ids() { Mockery::on( static function ( $query ) { return $query instanceof Posts_Query - && $query->get_include() === [ 5, 3 ]; + && $query->get_include_ids() === [ 5, 3 ]; }, ), ) From c59639b06ba55ec12853f364b78a864ba86a6e3a Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Thu, 23 Jul 2026 10:36:57 +0200 Subject: [PATCH 19/31] Fix cs --- src/bulk-editor/domain/posts/posts-query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bulk-editor/domain/posts/posts-query.php b/src/bulk-editor/domain/posts/posts-query.php index 01289b364e3..7dceca17c5c 100644 --- a/src/bulk-editor/domain/posts/posts-query.php +++ b/src/bulk-editor/domain/posts/posts-query.php @@ -86,7 +86,7 @@ public function __construct( $this->search = $search; $this->statuses = $statuses; $this->author_id = $author_id; - $this->include_ids = $include_ids; + $this->include_ids = $include_ids; } /** From fba93699461f9cf23820e5308526710fecfbb1b7 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Thu, 23 Jul 2026 12:21:31 +0200 Subject: [PATCH 20/31] Prune "invisible" posts from the selected list --- .../js/src/bulk-editor/services/use-posts.js | 13 ++++-- .../js/src/bulk-editor/store/selection.js | 7 +++ .../bulk-editor/services/use-posts.test.js | 46 ++++++++++++++++++- .../tests/bulk-editor/store/selection.test.js | 12 +++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/js/src/bulk-editor/services/use-posts.js b/packages/js/src/bulk-editor/services/use-posts.js index c78f9abf7da..9768f0e8811 100644 --- a/packages/js/src/bulk-editor/services/use-posts.js +++ b/packages/js/src/bulk-editor/services/use-posts.js @@ -1,4 +1,4 @@ -import { useSelect } from "@wordpress/data"; +import { useDispatch, useSelect } from "@wordpress/data"; import { useCallback, useEffect, useRef, useState } from "@wordpress/element"; import { PAGE_SIZE, STORE_NAME } from "../constants"; @@ -65,6 +65,7 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] ); const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] ); const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] ); + const { pruneSelection } = useDispatch( STORE_NAME ); const endpoint = dataProvider.getEndpoint( "posts" ); @@ -106,7 +107,13 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => ) .then( ( response ) => { if ( controller.current === current ) { - setState( formatResponse( response ) ); + const settled = formatResponse( response ); + setState( settled ); + // While the overview filter is active, the response is the authoritative subset of the + // carried-over selection (at most one page): prune ids it cannot offer for selection. + if ( isOverviewFilterActive && overviewIds.length > 0 ) { + pruneSelection( settled.data.filter( ( item ) => item.editable ).map( ( item ) => item.id ) ); + } } } ) .catch( ( error ) => { @@ -117,7 +124,7 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => } ); return () => current.abort(); - }, [ endpoint, contentType, remoteDataProvider, search, page, statuses, overviewIds, isOverviewFilterActive ] ); + }, [ endpoint, contentType, remoteDataProvider, search, page, statuses, overviewIds, isOverviewFilterActive, pruneSelection ] ); return { ...state, updateItem }; }; diff --git a/packages/js/src/bulk-editor/store/selection.js b/packages/js/src/bulk-editor/store/selection.js index cb32c8f7447..7c3fa51c447 100644 --- a/packages/js/src/bulk-editor/store/selection.js +++ b/packages/js/src/bulk-editor/store/selection.js @@ -30,6 +30,13 @@ const slice = createSlice( { state.preselectedTotal = 0; }, deselectAll: () => createInitialSelectionState(), + // A selection carried over from the WP admin overview can reference posts the bulk editor does not list + // (e.g. private posts) or lists with a disabled checkbox (non-editable rows). Once the shown result set is + // known, such ids must be dropped: they would stay selected without a way to deselect them, and leak into + // the bulk actions. + pruneSelection: ( state, { payload } ) => { + state.selectedIds = state.selectedIds.filter( ( id ) => payload.includes( id ) ); + }, dismissPreselectionNotice: ( state ) => { state.preselectedTotal = 0; }, diff --git a/packages/js/tests/bulk-editor/services/use-posts.test.js b/packages/js/tests/bulk-editor/services/use-posts.test.js index 4d686151756..f3a60dd89ef 100644 --- a/packages/js/tests/bulk-editor/services/use-posts.test.js +++ b/packages/js/tests/bulk-editor/services/use-posts.test.js @@ -1,13 +1,14 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { useSelect } from "@wordpress/data"; +import { useDispatch, useSelect } from "@wordpress/data"; import { usePosts } from "../../../src/bulk-editor/services/use-posts"; import { PAGE_SIZE } from "../../../src/bulk-editor/constants"; -jest.mock( "@wordpress/data", () => ( { useSelect: jest.fn() } ) ); +jest.mock( "@wordpress/data", () => ( { useSelect: jest.fn(), useDispatch: jest.fn() } ) ); describe( "usePosts", () => { let dataProvider; let storeState; + let pruneSelection; beforeEach( () => { dataProvider = { getEndpoint: jest.fn( () => "https://example.com/wp-json/yoast/v1/bulk_editor/posts" ) }; @@ -20,6 +21,8 @@ describe( "usePosts", () => { selectOverviewIds: () => storeState.overviewIds, selectIsOverviewFilterActive: () => storeState.isOverviewFilterActive, } ) ) ); + pruneSelection = jest.fn(); + useDispatch.mockReturnValue( { pruneSelection } ); } ); it( "requests the posts endpoint with the content type, page size, page, search and statuses", async() => { @@ -69,6 +72,45 @@ describe( "usePosts", () => { ); } ); + it( "prunes the selection to the editable listed rows while the overview filter is active", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3, 99 ], isOverviewFilterActive: true }; + const remoteDataProvider = { + // Post 99 is not listed at all; post 3 is listed but not editable, so its checkbox is disabled. + fetchJson: jest.fn( () => Promise.resolve( { posts: [ + { id: 5, title: "Listed", editable: true }, + { id: 3, title: "Locked", editable: false }, + ] } ) ), + }; + + const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( result.current.isPending ).toBe( false ) ); + + expect( pruneSelection ).toHaveBeenCalledWith( [ 5 ] ); + } ); + + it( "does not prune the selection while the overview filter is inactive", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: false }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [ { id: 5, title: "Listed", editable: true } ] } ) ) }; + + const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( result.current.isPending ).toBe( false ) ); + + expect( pruneSelection ).not.toHaveBeenCalled(); + } ); + + it( "does not prune the selection when the request fails", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: true }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.reject( new Error( "boom" ) ) ) }; + + const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( result.current.isPending ).toBe( false ) ); + + expect( pruneSelection ).not.toHaveBeenCalled(); + } ); + it( "maps the snake_case API rows to camelCase bulk editor rows and exposes the totals", async() => { const remoteDataProvider = { /* eslint-disable camelcase -- The REST endpoint returns snake_case fields. */ diff --git a/packages/js/tests/bulk-editor/store/selection.test.js b/packages/js/tests/bulk-editor/store/selection.test.js index a4916424aa5..aeeb0414311 100644 --- a/packages/js/tests/bulk-editor/store/selection.test.js +++ b/packages/js/tests/bulk-editor/store/selection.test.js @@ -38,6 +38,18 @@ describe( "selection slice", () => { expect( state.selectedIds ).toEqual( [] ); } ); + it( "prunes selected ids missing from the given selectable ids, keeping the carried-over selection total", () => { + const state = reducer( { selectedIds: [ 7, 9, 11 ], preselectedTotal: 25 }, selectionActions.pruneSelection( [ 9, 11, 13 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25 } ); + } ); + + it( "keeps the selection intact when all selected ids are selectable", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, selectionActions.pruneSelection( [ 7, 9, 11 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25 } ); + } ); + it( "clears the selection when the status filter changes", () => { const state = reducer( { selectedIds: [ 7, 9 ] }, queryActions.setStatuses( [ "draft" ] ) ); From d844333f1b69c8daf8b9c978ad699a6cf07b51c9 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Thu, 23 Jul 2026 16:26:59 +0200 Subject: [PATCH 21/31] Add a notice informing the user when some posts selected in the overview cannot be shown in the Bulk editor --- .../bulk-editor/components/bulk-action-bar.js | 17 ++- .../components/bulk-editor-content.js | 30 ++++- .../components/overview-exclusion-notice.js | 53 +++++++++ .../js/src/bulk-editor/store/selection.js | 18 ++- .../bulk-editor/bulk-editor-content.test.js | 11 +- .../overview-exclusion-notice.test.js | 106 ++++++++++++++++++ .../tests/bulk-editor/store/selection.test.js | 51 +++++++-- 7 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 packages/js/src/bulk-editor/components/overview-exclusion-notice.js create mode 100644 packages/js/tests/bulk-editor/overview-exclusion-notice.test.js 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 143239b34fa..f183f073957 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -9,6 +9,7 @@ import { __, _n, sprintf } from "@wordpress/i18n"; import { Alert, Button, Checkbox, DropdownMenu, useSvgAria, useToggleState } from "@yoast/ui-library"; import { BULK_ACTIONS_SLOT, BULK_NOTICES_SLOT, SELECT_MENU_ITEMS_FILTER } from "../constants"; import { useAiUpsell } from "../hooks/use-ai-upsell"; +import { OverviewExclusionNotice } from "./overview-exclusion-notice"; import { OverviewSelectionNotice } from "./overview-selection-notice"; import { UpsellModal } from "./upsell-modal"; @@ -202,12 +203,15 @@ export const ManualSaveErrorNotice = ( { onDismiss } ) => { }; /** - * The overview-selection truncation notice, the Free save-error notice, and the alerts slot Premium fills - * (e.g. its AI alerts). Only rendered on the active tab, so each tab has a single slot to target. + * The overview-selection truncation and exclusion notices, the Free save-error notice, and the alerts slot + * Premium fills (e.g. its AI alerts). Only rendered on the active tab, so each tab has a single slot to target. + * The truncation and exclusion notices are independent and can show at the same time. * * @param {Object} props The props. * @param {number} [props.preselectedTotal] How many items were selected on the WP admin overview; shows the truncation notice. * @param {Function} [props.onDismissPreselection] Dismisses the truncation notice. + * @param {boolean} [props.hasExcludedPreselected] Whether carried-over items were dropped; shows the exclusion notice. + * @param {Function} [props.onDismissExclusion] Dismisses the exclusion notice. * @param {boolean} [props.hasSaveError] Whether the last apply-all failed; shows the save-error notice. * @param {Function} [props.onDismissSaveError] Dismisses the save-error notice. * @param {number[]} props.selectedIds The ids of the selected rows, passed to the notices fill. @@ -219,11 +223,12 @@ export const ManualSaveErrorNotice = ( { onDismiss } ) => { * @returns {JSX.Element} The notices region. */ const BulkActionsNotices = ( { - preselectedTotal = 0, onDismissPreselection, hasSaveError, onDismissSaveError, + preselectedTotal = 0, onDismissPreselection, hasExcludedPreselected = false, onDismissExclusion, hasSaveError, onDismissSaveError, selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel, } ) => ( <> + { hasSaveError && } (
{ isActive && ( { * occupant); with AI off the band collapses. Unsaved manual edits are a separate, non-AI occupant, so they * keep it open regardless of the AI toggle. External pending changes (Premium's AI suggestions) also keep * it open: a filter, search, or page change clears the selection but must leave the pending suggestions - * actionable. The overview-selection truncation notice lives in the band's notices region, so it opens the - * band too. + * actionable. The overview-selection truncation and exclusion notices live in the band's notices region, + * so either opens the band too. * * @param {Object} view The view state. * @param {boolean} view.hasSelection Whether any rows are selected. * @param {boolean} view.isAiEnabled Whether the AI feature is enabled. * @param {boolean} view.hasUnsavedEdits Whether a row has unsaved manual edits. * @param {boolean} view.hasExternalPendingChanges Whether an external plugin reports pending changes. - * @param {boolean} view.hasOverviewNotice Whether the overview-selection truncation notice must show. + * @param {boolean} view.hasOverviewNotice Whether an overview-selection notice (truncation or exclusion) must show. * * @returns {boolean} Whether the band is expanded. */ export const shouldShowBulkActions = ( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ) => ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges || hasOverviewNotice; +/** + * Decides whether an overview-selection notice (truncation or exclusion) must show. + * + * @param {Object} view The view state. + * @param {number} view.preselectedTotal How many items were selected on the WP admin overview. + * @param {boolean} view.hasExcludedPreselected Whether pruning dropped carried-over ids. + * + * @returns {boolean} Whether an overview-selection notice must show. + */ +export const getHasOverviewNotice = ( { preselectedTotal, hasExcludedPreselected } ) => + preselectedTotal > BULK_UPDATE_BATCH_SIZE || hasExcludedPreselected; + /** * The bulk editor content. * @@ -88,6 +100,7 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy activeFieldSet, selectedIds, preselectedTotal, + hasExcludedPreselected, isPremium, isAiEnabled, hasExternalPendingChanges, @@ -100,6 +113,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy selectedIds: store.selectSelectedIds(), // The size of a selection carried over from the WP admin overview; drives the truncation notice. preselectedTotal: store.selectPreselectedTotal(), + // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice. + hasExcludedPreselected: store.selectHasExcludedPreselected(), isPremium: store.selectPreference( "isPremium", false ), isAiEnabled: store.selectPreference( "isAiEnabled", false ), // An external plugin (e.g. Premium's AI suggestions) reports pending changes so the switch can be guarded. @@ -110,7 +125,7 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy }; }, [] ); const { - requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll, dismissPreselectionNotice, + requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll, dismissPreselectionNotice, dismissExclusionNotice, } = useDispatch( STORE_NAME ); const { data: items = [], total = 0, totalPages = 0, isPending, updateItem } = usePosts( { dataProvider, remoteDataProvider, contentType } ); @@ -158,8 +173,9 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy }, [ pendingSwitch, hasUnsavedEdits, hasExternalPendingChanges, onCommitSwitch ] ); const { isAllSelected, isIndeterminate, selectedCount, totalCount, hasSelection } = getSelectionView( isPending, selectedIds, items, total ); - // The truncation notice for a selection carried over from the WP admin overview, shown in the band's notices region. - const hasOverviewNotice = preselectedTotal > BULK_UPDATE_BATCH_SIZE; + // The truncation and exclusion notices for a selection carried over from the WP admin overview, shown in the + // band's notices region; either one keeps the band expanded. + const hasOverviewNotice = getHasOverviewNotice( { preselectedTotal, hasExcludedPreselected } ); const showBulkActions = shouldShowBulkActions( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ); const onSelectAll = useCallback( () => { if ( ! isPending ) { @@ -226,6 +242,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy onDismissSaveError={ editing.dismissSaveError } preselectedTotal={ preselectedTotal } onDismissPreselection={ dismissPreselectionNotice } + hasExcludedPreselected={ hasExcludedPreselected } + onDismissExclusion={ dismissExclusionNotice } /> } showBulkActions={ showBulkActions } diff --git a/packages/js/src/bulk-editor/components/overview-exclusion-notice.js b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js new file mode 100644 index 00000000000..2726a5a39ab --- /dev/null +++ b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js @@ -0,0 +1,53 @@ +import SolidXIcon from "@heroicons/react/solid/XIcon"; +import { __, sprintf } from "@wordpress/i18n"; +import { Alert, useSvgAria } from "@yoast/ui-library"; + +/** + * Builds the notice message. + * + * @param {string} noun The lowercase content type label, e.g. "posts". + * + * @returns {string} The notice message. + */ +const getMessage = ( noun ) => sprintf( + /* translators: %s expands to the lowercase plural content type label, e.g. "posts". */ + __( "Your selection has been updated. Private, password-protected, or non-indexed %s can't be bulk edited and were excluded.", "wordpress-seo" ), + noun +); + +/** + * The notice shown when a selection carried over from a WP admin overview contained items the bulk editor + * cannot show or edit: those were dropped from the selection. Renders nothing while nothing was dropped. + * + * @param {Object} props The props. + * @param {boolean} props.hasExclusions Whether carried-over items were dropped from the selection. + * @param {string} [props.contentTypeLabel] The active content type label (plural), used in the copy. + * @param {Function} props.onDismiss Dismisses the notice. + * + * @returns {?JSX.Element} The notice, or null while nothing was dropped. + */ +export const OverviewExclusionNotice = ( { hasExclusions, contentTypeLabel, onDismiss } ) => { + const svgAriaProps = useSvgAria(); + + if ( ! hasExclusions ) { + return null; + } + + const noun = contentTypeLabel ? contentTypeLabel.toLowerCase() : __( "items", "wordpress-seo" ); + + return ( + // The top margin separates this notice from the truncation notice above it; it cancels out when + // nothing precedes it in the notices region (the truncation notice renders null when it does not apply). + + { getMessage( noun ) } + + + ); +}; diff --git a/packages/js/src/bulk-editor/store/selection.js b/packages/js/src/bulk-editor/store/selection.js index 7c3fa51c447..18bc2df7893 100644 --- a/packages/js/src/bulk-editor/store/selection.js +++ b/packages/js/src/bulk-editor/store/selection.js @@ -11,6 +11,9 @@ export const createInitialSelectionState = () => ( { // How many items were selected on the WP admin overview when the user came in via its bulk action; drives the // "only the first 20 are selected" notice. 0 when the user did not come in that way (or the notice was dismissed). preselectedTotal: 0, + // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice. + // False when nothing was dropped (or the notice was dismissed). + hasExcludedPreselected: false, } ); const slice = createSlice( { @@ -26,20 +29,28 @@ const slice = createSlice( { }, selectAll: ( state, { payload } ) => { state.selectedIds = [ ...payload ]; - // An explicit select-all replaces the carried-over selection, so its notice would be stale. + // An explicit select-all replaces the carried-over selection, so its notices would be stale. state.preselectedTotal = 0; + state.hasExcludedPreselected = false; }, deselectAll: () => createInitialSelectionState(), // A selection carried over from the WP admin overview can reference posts the bulk editor does not list // (e.g. private posts) or lists with a disabled checkbox (non-editable rows). Once the shown result set is // known, such ids must be dropped: they would stay selected without a way to deselect them, and leak into - // the bulk actions. + // the bulk actions. Dropping any flags the exclusion notice, so the user learns why their selection shrank. pruneSelection: ( state, { payload } ) => { - state.selectedIds = state.selectedIds.filter( ( id ) => payload.includes( id ) ); + const pruned = state.selectedIds.filter( ( id ) => payload.includes( id ) ); + if ( pruned.length < state.selectedIds.length ) { + state.hasExcludedPreselected = true; + } + state.selectedIds = pruned; }, dismissPreselectionNotice: ( state ) => { state.preselectedTotal = 0; }, + dismissExclusionNotice: ( state ) => { + state.hasExcludedPreselected = false; + }, }, extraReducers: ( builder ) => { // Any change to the shown result set resets the selection: the new set may no longer contain the selected rows. @@ -54,6 +65,7 @@ const slice = createSlice( { export const selectionSelectors = { selectSelectedIds: ( state ) => get( state, "selection.selectedIds", [] ), selectPreselectedTotal: ( state ) => get( state, "selection.preselectedTotal", 0 ), + selectHasExcludedPreselected: ( state ) => get( state, "selection.hasExcludedPreselected", false ), }; export const selectionActions = slice.actions; diff --git a/packages/js/tests/bulk-editor/bulk-editor-content.test.js b/packages/js/tests/bulk-editor/bulk-editor-content.test.js index 76ad21a3fa4..0b586d97379 100644 --- a/packages/js/tests/bulk-editor/bulk-editor-content.test.js +++ b/packages/js/tests/bulk-editor/bulk-editor-content.test.js @@ -1,7 +1,7 @@ import { Fill, SlotFillProvider } from "@wordpress/components"; import { dispatch } from "@wordpress/data"; import { act, fireEvent, render, screen, waitFor } from "../test-utils"; -import { BulkEditorContent, getSelectionView, shouldShowBulkActions } from "../../src/bulk-editor/components/bulk-editor-content"; +import { BulkEditorContent, getHasOverviewNotice, getSelectionView, shouldShowBulkActions } from "../../src/bulk-editor/components/bulk-editor-content"; import { FIELD_SET_SEARCH, PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../../src/bulk-editor/constants"; import { DataProvider } from "../../src/bulk-editor/services"; import registerStore from "../../src/bulk-editor/store"; @@ -167,6 +167,15 @@ describe( "shouldShowBulkActions", () => { } ); } ); +describe( "getHasOverviewNotice", () => { + it( "reports a notice for a truncated or a pruned carried-over selection, but not for a fitting one", () => { + expect( getHasOverviewNotice( { preselectedTotal: 0, hasExcludedPreselected: false } ) ).toBe( false ); + expect( getHasOverviewNotice( { preselectedTotal: 20, hasExcludedPreselected: false } ) ).toBe( false ); + expect( getHasOverviewNotice( { preselectedTotal: 25, hasExcludedPreselected: false } ) ).toBe( true ); + expect( getHasOverviewNotice( { preselectedTotal: 3, hasExcludedPreselected: true } ) ).toBe( true ); + } ); +} ); + describe( "BulkEditorContent tab-switch guard", () => { it( "switches immediately when nothing guards the switch", () => { renderContent(); diff --git a/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js new file mode 100644 index 00000000000..2b065d2287b --- /dev/null +++ b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js @@ -0,0 +1,106 @@ +import { SlotFillProvider } from "@wordpress/components"; +import { OverviewExclusionNotice } from "../../src/bulk-editor/components/overview-exclusion-notice"; +import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content"; +import { DataProvider } from "../../src/bulk-editor/services"; +import registerStore from "../../src/bulk-editor/store"; +import { fireEvent, render, screen, within } from "../test-utils"; + +const NOTICE_TEXT = "Your selection has been updated. Private, password-protected, or non-indexed posts can't be bulk edited and were excluded."; +const TRUNCATION_TEXT = "Only the first 20 posts from your selection were carried over. The bulk editor supports up to 20 posts at a time."; + +describe( "OverviewExclusionNotice", () => { + it( "explains that carried-over items were excluded, naming the content type", () => { + render( ); + + expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); + } ); + + it( "falls back to a generic noun without a content type label", () => { + render( ); + + expect( screen.getByText( + "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded." + ) ).toBeInTheDocument(); + } ); + + it( "calls onDismiss when the dismiss button is clicked", () => { + const onDismiss = jest.fn(); + render( ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + expect( onDismiss ).toHaveBeenCalledTimes( 1 ); + } ); + + it( "renders nothing while nothing was excluded", () => { + const { container } = render( ); + + expect( container ).toBeEmptyDOMElement(); + } ); +} ); + +describe( "BulkEditorContent with a truncated and pruned carried-over selection", () => { + const dataProvider = new DataProvider( { + contentTypes: [ { name: "post", label: "Posts", singularLabel: "Post" } ], + endpoints: { posts: "https://example.com/wp-json/yoast/v1/bulk_editor/posts" }, + links: {}, + } ); + // The data layer is covered by use-posts.test.js; the request stays pending here. + const remoteDataProvider = { fetchJson: jest.fn( () => new Promise( () => {} ) ) }; + + // The store registers once for the whole file: the WP data registry is global, so a second register would + // clash. Seeded as if 25 items were selected on the overview and pruning dropped some of the carried 20. + beforeAll( () => { + registerStore( { + initialState: { + activeContentType: "post", + selection: { + selectedIds: Array.from( { length: 18 }, ( _, index ) => index + 1 ), + preselectedTotal: 25, + hasExcludedPreselected: true, + }, + }, + } ); + } ); + + const renderContent = () => render( + + + + ); + + it( "shows the truncation and exclusion notices together, dismissible independently", () => { + const { container } = renderContent(); + + const truncation = screen.getByText( TRUNCATION_TEXT ); + const exclusion = screen.getByText( NOTICE_TEXT ); + expect( truncation ).toBeInTheDocument(); + expect( exclusion ).toBeInTheDocument(); + // Both render inside the expanded bulk-actions row, the truncation notice first. + expect( exclusion.closest( "tr" ) ).toBe( truncation.closest( "tr" ) ); + expect( truncation.closest( "tr" ) ).toHaveAttribute( "aria-hidden", "false" ); + // eslint-disable-next-line no-bitwise -- compareDocumentPosition returns a bitmask. + expect( truncation.compareDocumentPosition( exclusion ) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); + + fireEvent.click( within( exclusion.closest( "[role='status']" ) ).getByRole( "button", { name: "Dismiss" } ) ); + + // The exclusion notice is gone; the truncation notice keeps the row expanded. + expect( screen.queryByText( NOTICE_TEXT ) ).not.toBeInTheDocument(); + expect( screen.getByText( TRUNCATION_TEXT ) ).toBeInTheDocument(); + expect( screen.getByText( TRUNCATION_TEXT ).closest( "tr" ) ).toHaveAttribute( "aria-hidden", "false" ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + // Nothing else occupies the band here (AI is disabled, no edits), so dismissing both collapses the row. + expect( screen.queryByText( TRUNCATION_TEXT ) ).not.toBeInTheDocument(); + container.querySelectorAll( "tr[aria-hidden]" ).forEach( ( row ) => { + expect( row ).toHaveAttribute( "aria-hidden", "true" ); + } ); + } ); +} ); diff --git a/packages/js/tests/bulk-editor/store/selection.test.js b/packages/js/tests/bulk-editor/store/selection.test.js index aeeb0414311..600899c8509 100644 --- a/packages/js/tests/bulk-editor/store/selection.test.js +++ b/packages/js/tests/bulk-editor/store/selection.test.js @@ -3,8 +3,8 @@ import reducer, { createInitialSelectionState, selectionActions, selectionSelect import { queryActions } from "../../../src/bulk-editor/store/query"; describe( "selection slice", () => { - it( "defaults to no rows selected and no carried-over selection", () => { - expect( createInitialSelectionState() ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + it( "defaults to no rows selected, no carried-over selection and no exclusions", () => { + expect( createInitialSelectionState() ).toEqual( { selectedIds: [], preselectedTotal: 0, hasExcludedPreselected: false } ); } ); it( "adds a row to the selection when toggled on", () => { @@ -38,16 +38,45 @@ describe( "selection slice", () => { expect( state.selectedIds ).toEqual( [] ); } ); - it( "prunes selected ids missing from the given selectable ids, keeping the carried-over selection total", () => { + it( "prunes selected ids missing from the given selectable ids, keeping the carried-over selection total and flagging the exclusion", () => { const state = reducer( { selectedIds: [ 7, 9, 11 ], preselectedTotal: 25 }, selectionActions.pruneSelection( [ 9, 11, 13 ] ) ); - expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25 } ); + expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: true } ); } ); - it( "keeps the selection intact when all selected ids are selectable", () => { - const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, selectionActions.pruneSelection( [ 7, 9, 11 ] ) ); + it( "keeps the selection intact and unflagged when all selected ids are selectable", () => { + const state = reducer( + { selectedIds: [ 7, 9 ], preselectedTotal: 25, hasExcludedPreselected: false }, + selectionActions.pruneSelection( [ 7, 9, 11 ] ) + ); - expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25 } ); + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25, hasExcludedPreselected: false } ); + } ); + + it( "clears the exclusion flag on dismissExclusionNotice, keeping the selection and the carried-over total", () => { + const state = reducer( + { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: true }, + selectionActions.dismissExclusionNotice() + ); + + expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: false } ); + } ); + + it( "clears the exclusion flag on selectAll", () => { + const state = reducer( { selectedIds: [ 9 ], preselectedTotal: 25, hasExcludedPreselected: true }, selectionActions.selectAll( [ 7, 9 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0, hasExcludedPreselected: false } ); + } ); + + it( "clears the exclusion flag when the shown result set changes", () => { + const state = reducer( { selectedIds: [ 9 ], preselectedTotal: 25, hasExcludedPreselected: true }, queryActions.setPage( 2 ) ); + + expect( state ).toEqual( createInitialSelectionState() ); + } ); + + it( "selects the exclusion flag, defaulting to false when missing", () => { + expect( selectionSelectors.selectHasExcludedPreselected( { selection: { hasExcludedPreselected: true } } ) ).toBe( true ); + expect( selectionSelectors.selectHasExcludedPreselected( {} ) ).toBe( false ); } ); it( "clears the selection when the status filter changes", () => { @@ -88,13 +117,13 @@ describe( "selection slice", () => { it( "clears the carried-over selection total on selectAll", () => { const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.selectAll( [ 7, 9 ] ) ); - expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0 } ); + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0, hasExcludedPreselected: false } ); } ); it( "clears the carried-over selection total on deselectAll", () => { const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.deselectAll() ); - expect( state ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + expect( state ).toEqual( createInitialSelectionState() ); } ); it( "keeps the carried-over selection total when a single row is toggled", () => { @@ -106,13 +135,13 @@ describe( "selection slice", () => { it( "clears the carried-over selection total when the shown result set changes", () => { const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setPage( 2 ) ); - expect( state ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + expect( state ).toEqual( createInitialSelectionState() ); } ); it( "clears the selection when the overview filter is toggled", () => { const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setOverviewFilterActive( false ) ); - expect( state ).toEqual( { selectedIds: [], preselectedTotal: 0 } ); + expect( state ).toEqual( createInitialSelectionState() ); } ); it( "selects the carried-over selection total, defaulting to 0 when missing", () => { From c9ed57c81218ec2f8adcd64b85289ed3c9f85fe3 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Mon, 27 Jul 2026 15:32:49 +0200 Subject: [PATCH 22/31] Don't translate the product name --- .../user-interface/posts-overview-bulk-actions-integration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php index 1340685aaba..97207887b1c 100644 --- a/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php +++ b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php @@ -106,7 +106,7 @@ public function add_bulk_action( $actions ) { // A nested array renders as an optgroup (since WP 5.6): the default actions stay first, followed // by a visually separated "Yoast SEO" group holding the entry. - $actions[ \__( 'Yoast SEO', 'wordpress-seo' ) ] = [ + $actions['Yoast SEO'] = [ self::BULK_ACTION => \__( 'Bulk edit', 'wordpress-seo' ) . ' ' . $arrow, ]; From c0f4cb721e3c88ed682acafbbf2ccf8dc2cdc670 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Mon, 27 Jul 2026 15:33:04 +0200 Subject: [PATCH 23/31] Proper check and sanitization --- .../user-interface/bulk-editor-integration.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index 325723c9ce4..b502a00553d 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -302,11 +302,15 @@ static function ( $id ) { $initial_selection['selectedCount'] = \count( $post_ids ); } - if ( $initial_selection['postIds'] !== [] && isset( $_GET[ self::SELECTED_COUNT_PARAM ] ) ) { + if ( + $initial_selection['postIds'] !== [] + && isset( $_GET[ self::SELECTED_COUNT_PARAM ] ) + && \is_string( $_GET[ self::SELECTED_COUNT_PARAM ] ) + ) { // The count can only grow beyond the carried IDs, never shrink below them. $initial_selection['selectedCount'] = \max( $initial_selection['selectedCount'], - (int) $_GET[ self::SELECTED_COUNT_PARAM ], + \absint( \wp_unslash( $_GET[ self::SELECTED_COUNT_PARAM ] ) ), ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended From a26559c72b062682e01f2a1b545814c4abaf1b37 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Mon, 27 Jul 2026 15:33:18 +0200 Subject: [PATCH 24/31] Add test --- .../Get_Initial_Selection_Test.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php index 531a473b19d..ad320d5fdf6 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php @@ -146,6 +146,26 @@ public function test_selected_count_never_undercuts_the_post_ids() { ); } + /** + * Tests that a non-scalar selected count is ignored. + * + * @return void + */ + public function test_ignores_a_non_scalar_selected_count() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3'; + $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = [ '25' ]; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [ 1, 2, 3 ], + 'selectedCount' => 3, + ], + $this->get_initial_selection(), + ); + } + /** * Primes every collaborator the script data needs and returns the initial selection part. * From 09b797b8a58b9eca87f6ebb565b6001e2d26ccb9 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Mon, 27 Jul 2026 15:56:45 +0200 Subject: [PATCH 25/31] Add bottom divider to the Overview selection filter like the one separating the "needs improvement group" filters --- css/src/bulk-editor-page.css | 6 ++++++ .../js/src/bulk-editor/components/bulk-editor-filters.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index 6e0b92e72ef..cbb90ec9847 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -102,6 +102,12 @@ @apply yst-m-0; } +/* The "Overview selection" filter group. It is separated from the status filters below it by a divider, + * mirroring the one above the "needs improvement" group. */ +.yst-root .yst-bulk-editor-overview-selection { + @apply yst-border-b yst-border-slate-200 yst-mb-0.5 yst-pb-0.5; +} + /* The "needs improvement" filter group. It is separated from the status filters by a divider; each option is * prefixed with a red score dot; and the group's legend is hidden visually but kept for assistive tech, so the * dot's "needs improvement" meaning is never conveyed by colour alone. */ diff --git a/packages/js/src/bulk-editor/components/bulk-editor-filters.js b/packages/js/src/bulk-editor/components/bulk-editor-filters.js index 0dab4c8797d..1d029a474f0 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-filters.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-filters.js @@ -110,7 +110,7 @@ export const BulkEditorFilters = () => { { overviewIds.length > 0 && ( Date: Wed, 29 Jul 2026 11:58:18 +0200 Subject: [PATCH 26/31] Add comment --- packages/js/src/bulk-editor/store/selection.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/js/src/bulk-editor/store/selection.js b/packages/js/src/bulk-editor/store/selection.js index 18bc2df7893..cabec6463ce 100644 --- a/packages/js/src/bulk-editor/store/selection.js +++ b/packages/js/src/bulk-editor/store/selection.js @@ -54,6 +54,8 @@ const slice = createSlice( { }, extraReducers: ( builder ) => { // Any change to the shown result set resets the selection: the new set may no longer contain the selected rows. + // The "Overview selection" filter (query slice) intentionally outlives these resets: it narrows the result set + // like any other filter, while the carried-over selection itself is one-shot and never restored. builder.addCase( queryActions.setStatuses, () => createInitialSelectionState() ); builder.addCase( queryActions.setSearch, () => createInitialSelectionState() ); builder.addCase( queryActions.setPage, () => createInitialSelectionState() ); From 7cace7050f06afb645895c0b96d2db98ce9f3f56 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Wed, 29 Jul 2026 11:58:47 +0200 Subject: [PATCH 27/31] Add test --- .../Build_Query_Args_Test.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php new file mode 100644 index 00000000000..96aed9e9353 --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php @@ -0,0 +1,68 @@ + + */ + private const STATUSES = [ 'publish', 'draft', 'pending', 'future' ]; + + /** + * Tests that the included post IDs restrict the query through post__in. + * + * @return void + */ + public function test_build_query_args_restricts_to_the_included_post_ids() { + $args = $this->invoke_build_query_args( new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [], true, [ 5, 3 ] ) ); + + $this->assertSame( [ 5, 3 ], $args['post__in'] ); + } + + /** + * Tests that post__in is left out when no post IDs are included. + * + * @return void + */ + public function test_build_query_args_without_included_post_ids() { + $args = $this->invoke_build_query_args( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) ); + + $this->assertArrayNotHasKey( 'post__in', $args ); + } + + /** + * Invokes the private build_query_args on a real collector instance. + * + * The construction of the WP_Query consuming these arguments cannot be intercepted (the class name is + * already declared as a plain mock elsewhere in the suite, which rules out an overload mock), so the + * built arguments are asserted directly instead. + * + * @param Posts_Query $query The query describing the page to collect. + * + * @return array|array> The built WP_Query arguments. + */ + private function invoke_build_query_args( Posts_Query $query ): array { + $instance = new Post_Meta_Posts_Collector( $this->post_editability_resolver ); + + $reflection = new ReflectionMethod( $instance, 'build_query_args' ); + $reflection->setAccessible( true ); + + return $reflection->invoke( $instance, $query ); + } +} From c84ff4020f22eeeeb402b3bbd9edc1c0f3921ae1 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Fri, 31 Jul 2026 15:09:12 +0200 Subject: [PATCH 28/31] Extract reusable alert --- .../components/dismissible-alert.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/js/src/bulk-editor/components/dismissible-alert.js diff --git a/packages/js/src/bulk-editor/components/dismissible-alert.js b/packages/js/src/bulk-editor/components/dismissible-alert.js new file mode 100644 index 00000000000..851280dece3 --- /dev/null +++ b/packages/js/src/bulk-editor/components/dismissible-alert.js @@ -0,0 +1,34 @@ +import SolidXIcon from "@heroicons/react/solid/XIcon"; +import { __ } from "@wordpress/i18n"; +import { Alert, useSvgAria } from "@yoast/ui-library"; +import classNames from "classnames"; + +/** + * An alert with a dismiss (X) button in its top-end corner, shared by the notices in the bulk-actions band. + * + * @param {Object} props The props. + * @param {string} [props.variant] The alert variant. + * @param {string} [props.role] The alert role. + * @param {string} [props.className] Extra class names for the alert. + * @param {Function} props.onDismiss Dismisses the notice. + * @param {JSX.node} props.children The notice content. + * + * @returns {JSX.Element} The dismissible alert. + */ +export const DismissibleAlert = ( { variant = "info", role = "status", className = "", onDismiss, children } ) => { + const svgAriaProps = useSvgAria(); + + return ( + + { children } + + + ); +}; From 894affeb268600dab8307dfa1a6086844defeddf Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Fri, 31 Jul 2026 15:11:19 +0200 Subject: [PATCH 29/31] Use new alert component --- .../bulk-editor/components/bulk-action-bar.js | 21 ++++------- .../components/overview-exclusion-notice.js | 34 ++++-------------- .../components/overview-selection-notice.js | 36 +++++-------------- 3 files changed, 22 insertions(+), 69 deletions(-) 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 1037b300fb8..7d507fba525 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -1,12 +1,12 @@ import CheckIcon from "@heroicons/react/outline/CheckIcon"; import XIcon from "@heroicons/react/outline/XIcon"; -import SolidXIcon from "@heroicons/react/solid/XIcon"; import { Slot } from "@wordpress/components"; import { useEffect, useId, useRef } from "@wordpress/element"; import { __, _n, sprintf } from "@wordpress/i18n"; -import { Alert, Button, Checkbox, useSvgAria, useToggleState } from "@yoast/ui-library"; +import { Button, Checkbox, useSvgAria, useToggleState } from "@yoast/ui-library"; import { BULK_ACTIONS_SLOT, BULK_NOTICES_SLOT } from "../constants"; import { useAiUpsell } from "../hooks/use-ai-upsell"; +import { DismissibleAlert } from "./dismissible-alert"; import { OverviewExclusionNotice } from "./overview-exclusion-notice"; import { OverviewSelectionNotice } from "./overview-selection-notice"; import { UpsellModal } from "./upsell-modal"; @@ -144,23 +144,14 @@ export const ManualReviewActions = ( { editCount, onApplyAll, onDiscardAll, isAp * * @returns {JSX.Element} The save-error notice. */ -export const ManualSaveErrorNotice = ( { onDismiss } ) => { - const svgAriaProps = useSvgAria(); - return +export const ManualSaveErrorNotice = ( { onDismiss } ) => ( +
{ __( "Couldn't save your edits.", "wordpress-seo" ) } { __( "Something went wrong. Please try again.", "wordpress-seo" ) }
- -
; -}; + +); /** * The overview-selection truncation and exclusion notices, the Free save-error notice, and the alerts slot diff --git a/packages/js/src/bulk-editor/components/overview-exclusion-notice.js b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js index 2726a5a39ab..2bfaf0c6feb 100644 --- a/packages/js/src/bulk-editor/components/overview-exclusion-notice.js +++ b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js @@ -1,19 +1,5 @@ -import SolidXIcon from "@heroicons/react/solid/XIcon"; -import { __, sprintf } from "@wordpress/i18n"; -import { Alert, useSvgAria } from "@yoast/ui-library"; - -/** - * Builds the notice message. - * - * @param {string} noun The lowercase content type label, e.g. "posts". - * - * @returns {string} The notice message. - */ -const getMessage = ( noun ) => sprintf( - /* translators: %s expands to the lowercase plural content type label, e.g. "posts". */ - __( "Your selection has been updated. Private, password-protected, or non-indexed %s can't be bulk edited and were excluded.", "wordpress-seo" ), - noun -); +import { __ } from "@wordpress/i18n"; +import { DismissibleAlert } from "./dismissible-alert"; /** * The notice shown when a selection carried over from a WP admin overview contained items the bulk editor @@ -38,16 +24,10 @@ export const OverviewExclusionNotice = ( { hasExclusions, contentTypeLabel, onDi return ( // The top margin separates this notice from the truncation notice above it; it cancels out when // nothing precedes it in the notices region (the truncation notice renders null when it does not apply). - - { getMessage( noun ) } - - + + + { __( "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded.", "wordpress-seo" ) } + + ); }; diff --git a/packages/js/src/bulk-editor/components/overview-selection-notice.js b/packages/js/src/bulk-editor/components/overview-selection-notice.js index 00c749e2508..438c3c1d04a 100644 --- a/packages/js/src/bulk-editor/components/overview-selection-notice.js +++ b/packages/js/src/bulk-editor/components/overview-selection-notice.js @@ -1,21 +1,6 @@ -import SolidXIcon from "@heroicons/react/solid/XIcon"; import { __, sprintf } from "@wordpress/i18n"; -import { Alert, useSvgAria } from "@yoast/ui-library"; import { BULK_UPDATE_BATCH_SIZE } from "../constants"; - -/** - * Builds the notice message. - * - * @param {string} noun The lowercase content type label, e.g. "posts". - * - * @returns {string} The notice message. - */ -const getMessage = ( noun ) => sprintf( - /* translators: %1$d expands to the maximum number of items at a time, %2$s to the lowercase content type label, e.g. "posts". */ - __( "Only the first %1$d %2$s from your selection were carried over. The bulk editor supports up to %1$d %2$s at a time.", "wordpress-seo" ), - BULK_UPDATE_BATCH_SIZE, - noun -); +import { DismissibleAlert } from "./dismissible-alert"; /** * The notice shown when the user arrived from a WP admin overview with more items selected than the @@ -37,18 +22,15 @@ export const OverviewSelectionNotice = ( { total, contentTypeLabel, onDismiss } } const noun = contentTypeLabel ? contentTypeLabel.toLowerCase() : __( "items", "wordpress-seo" ); + const message = sprintf( + /* translators: %1$d expands to the maximum number of items at a time. */ + __( "Only the first %1$d items from your selection were carried over. The bulk editor supports up to %1$d items at a time.", "wordpress-seo" ), + BULK_UPDATE_BATCH_SIZE + ); return ( - - { getMessage( noun ) } - - + + { message } + ); }; From bc5eb53906eb001fad7074cfccc001d8a1c935cd Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Fri, 31 Jul 2026 15:11:39 +0200 Subject: [PATCH 30/31] Use generic "item" instead of content type in copy --- .../src/bulk-editor/components/bulk-action-bar.js | 4 ++-- .../components/overview-exclusion-notice.js | 13 ++++--------- .../components/overview-selection-notice.js | 12 ++++-------- 3 files changed, 10 insertions(+), 19 deletions(-) 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 7d507fba525..345ed34b6a6 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -178,8 +178,8 @@ const BulkActionsNotices = ( { selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel, } ) => ( <> - - + + { hasSaveError && } { - const svgAriaProps = useSvgAria(); - +export const OverviewExclusionNotice = ( { hasExclusions, onDismiss } ) => { if ( ! hasExclusions ) { return null; } - const noun = contentTypeLabel ? contentTypeLabel.toLowerCase() : __( "items", "wordpress-seo" ); - return ( // The top margin separates this notice from the truncation notice above it; it cancels out when // nothing precedes it in the notices region (the truncation notice renders null when it does not apply). diff --git a/packages/js/src/bulk-editor/components/overview-selection-notice.js b/packages/js/src/bulk-editor/components/overview-selection-notice.js index 438c3c1d04a..847c77faa4d 100644 --- a/packages/js/src/bulk-editor/components/overview-selection-notice.js +++ b/packages/js/src/bulk-editor/components/overview-selection-notice.js @@ -7,21 +7,17 @@ import { DismissibleAlert } from "./dismissible-alert"; * bulk editor can handle in one batch: only the first batch stays selected. Renders nothing while the * whole selection fits the batch. * - * @param {Object} props The props. - * @param {number} props.total The number of items that were selected on the overview. - * @param {string} [props.contentTypeLabel] The active content type label (plural), used in the copy. - * @param {Function} props.onDismiss Dismisses the notice. + * @param {Object} props The props. + * @param {number} props.total The number of items that were selected on the overview. + * @param {Function} props.onDismiss Dismisses the notice. * * @returns {?JSX.Element} The notice, or null when the whole selection fits the batch. */ -export const OverviewSelectionNotice = ( { total, contentTypeLabel, onDismiss } ) => { - const svgAriaProps = useSvgAria(); - +export const OverviewSelectionNotice = ( { total, onDismiss } ) => { if ( total <= BULK_UPDATE_BATCH_SIZE ) { return null; } - const noun = contentTypeLabel ? contentTypeLabel.toLowerCase() : __( "items", "wordpress-seo" ); const message = sprintf( /* translators: %1$d expands to the maximum number of items at a time. */ __( "Only the first %1$d items from your selection were carried over. The bulk editor supports up to %1$d items at a time.", "wordpress-seo" ), From 6cd71e6970be0a0f119acdec1ed53c8c71a67a44 Mon Sep 17 00:00:00 2001 From: "Paolo L. Scala" Date: Fri, 31 Jul 2026 15:11:49 +0200 Subject: [PATCH 31/31] Update tests --- .../overview-exclusion-notice.test.js | 20 ++++++------------- .../overview-selection-notice.test.js | 18 +++++------------ 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js index 2b065d2287b..81360b9063f 100644 --- a/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js +++ b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js @@ -5,27 +5,19 @@ import { DataProvider } from "../../src/bulk-editor/services"; import registerStore from "../../src/bulk-editor/store"; import { fireEvent, render, screen, within } from "../test-utils"; -const NOTICE_TEXT = "Your selection has been updated. Private, password-protected, or non-indexed posts can't be bulk edited and were excluded."; -const TRUNCATION_TEXT = "Only the first 20 posts from your selection were carried over. The bulk editor supports up to 20 posts at a time."; +const NOTICE_TEXT = "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded."; +const TRUNCATION_TEXT = "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time."; describe( "OverviewExclusionNotice", () => { - it( "explains that carried-over items were excluded, naming the content type", () => { - render( ); - - expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); - } ); - - it( "falls back to a generic noun without a content type label", () => { + it( "explains that carried-over items were excluded", () => { render( ); - expect( screen.getByText( - "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded." - ) ).toBeInTheDocument(); + expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); } ); it( "calls onDismiss when the dismiss button is clicked", () => { const onDismiss = jest.fn(); - render( ); + render( ); fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); @@ -33,7 +25,7 @@ describe( "OverviewExclusionNotice", () => { } ); it( "renders nothing while nothing was excluded", () => { - const { container } = render( ); + const { container } = render( ); expect( container ).toBeEmptyDOMElement(); } ); diff --git a/packages/js/tests/bulk-editor/overview-selection-notice.test.js b/packages/js/tests/bulk-editor/overview-selection-notice.test.js index 6a472ae6bce..eb2ba6a3298 100644 --- a/packages/js/tests/bulk-editor/overview-selection-notice.test.js +++ b/packages/js/tests/bulk-editor/overview-selection-notice.test.js @@ -5,26 +5,18 @@ import { DataProvider } from "../../src/bulk-editor/services"; import registerStore from "../../src/bulk-editor/store"; import { fireEvent, render, screen } from "../test-utils"; -const NOTICE_TEXT = "Only the first 20 posts from your selection were carried over. The bulk editor supports up to 20 posts at a time."; +const NOTICE_TEXT = "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time."; describe( "OverviewSelectionNotice", () => { - it( "explains that only the first batch of the overview selection is selected, naming the content type", () => { - render( ); - - expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); - } ); - - it( "falls back to a generic noun without a content type label", () => { + it( "explains that only the first batch of the overview selection is selected", () => { render( ); - expect( screen.getByText( - "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time." - ) ).toBeInTheDocument(); + expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); } ); it( "calls onDismiss when the dismiss button is clicked", () => { const onDismiss = jest.fn(); - render( ); + render( ); fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); @@ -32,7 +24,7 @@ describe( "OverviewSelectionNotice", () => { } ); it( "renders nothing while the whole selection fits the batch", () => { - const { container } = render( ); + const { container } = render( ); expect( container ).toBeEmptyDOMElement(); } );