• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / 713d88a3d6333a68824770e62f5dc4868a5abcea

24 Jul 2026 11:46AM UTC coverage: 55.559% (+0.2%) from 55.357%
713d88a3d6333a68824770e62f5dc4868a5abcea

Pull #23438

github

web-flow
Merge 26863cda3 into 6a2466a74
Pull Request #23438: feat(bulk-editor): add "needs improvement" filter options to the filters dropdown

10598 of 18901 branches covered (56.07%)

Branch coverage included in aggregate %.

394 of 447 new or added lines in 28 files covered. (88.14%)

3 existing lines in 3 files now uncovered.

40414 of 72915 relevant lines covered (55.43%)

41275.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.06
/src/bulk-editor/user-interface/posts-route.php
1
<?php
2

3
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4
namespace Yoast\WP\SEO\Bulk_Editor\User_Interface;
5

6
use WP_Error;
7
use WP_REST_Request;
8
use WP_REST_Response;
9
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Type_Access_Checker_Interface;
10
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
11
use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Collector_Interface;
12
use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Repository;
13
use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query;
14
use Yoast\WP\SEO\Conditionals\No_Conditionals;
15
use Yoast\WP\SEO\Helpers\Options_Helper;
16
use Yoast\WP\SEO\Helpers\User_Helper;
17
use Yoast\WP\SEO\Main;
18
use Yoast\WP\SEO\Routes\Route_Interface;
19

20
/**
21
 * Registers a route that returns a page of posts with their SEO and social meta.
22
 */
23
class Posts_Route implements Route_Interface {
24

25
        use No_Conditionals;
26

27
        /**
28
         * The namespace for this route.
29
         *
30
         * @var string
31
         */
32
        public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE;
33

34
        /**
35
         * The prefix for this route.
36
         *
37
         * @var string
38
         */
39
        public const ROUTE_PREFIX = '/bulk_editor/posts';
40

41
        /**
42
         * The default number of posts to return.
43
         *
44
         * @var int
45
         */
46
        public const DEFAULT_PER_PAGE = 20;
47

48
        /**
49
         * The maximum number of posts that can be requested in one page.
50
         *
51
         * @var int
52
         */
53
        public const MAX_PER_PAGE = 100;
54

55
        /**
56
         * The posts repository.
57
         *
58
         * @var Posts_Repository
59
         */
60
        private $posts_repository;
61

62
        /**
63
         * The content types repository.
64
         *
65
         * @var Content_Types_Repository
66
         */
67
        private $content_types_repository;
68

69
        /**
70
         * The content type access checker.
71
         *
72
         * @var Content_Type_Access_Checker_Interface
73
         */
74
        private $content_type_access_checker;
75

76
        /**
77
         * The user helper.
78
         *
79
         * @var User_Helper
80
         */
81
        private $user_helper;
82

83
        /**
84
         * The options helper.
85
         *
86
         * @var Options_Helper
87
         */
88
        private $options_helper;
89

90
        /**
91
         * The constructor.
92
         *
93
         * @param Posts_Repository                      $posts_repository            The posts repository.
94
         * @param Content_Types_Repository              $content_types_repository    The content types repository.
95
         * @param Content_Type_Access_Checker_Interface $content_type_access_checker The content type access checker.
96
         * @param User_Helper                           $user_helper                 The user helper.
97
         * @param Options_Helper                        $options_helper              The options helper.
98
         */
99
        public function __construct(
×
100
                Posts_Repository $posts_repository,
101
                Content_Types_Repository $content_types_repository,
102
                Content_Type_Access_Checker_Interface $content_type_access_checker,
103
                User_Helper $user_helper,
104
                Options_Helper $options_helper
105
        ) {
106
                $this->posts_repository            = $posts_repository;
×
107
                $this->content_types_repository    = $content_types_repository;
×
108
                $this->content_type_access_checker = $content_type_access_checker;
×
109
                $this->user_helper                 = $user_helper;
×
NEW
110
                $this->options_helper              = $options_helper;
×
111
        }
112

113
        /**
114
         * Registers routes with WordPress.
115
         *
116
         * @return void
117
         */
118
        public function register_routes() {
2✔
119
                \register_rest_route(
2✔
120
                        self::ROUTE_NAMESPACE,
2✔
121
                        self::ROUTE_PREFIX,
2✔
122
                        [
2✔
123
                                'methods'             => 'GET',
2✔
124
                                'args'                => [
2✔
125
                                        'content_type'      => [
2✔
126
                                                'required'          => true,
2✔
127
                                                'type'              => 'string',
2✔
128
                                                'description'       => 'The content type to fetch posts for.',
2✔
129
                                                'sanitize_callback' => 'sanitize_text_field',
2✔
130
                                        ],
2✔
131
                                        'per_page'          => [
2✔
132
                                                'required'          => false,
2✔
133
                                                'type'              => 'integer',
2✔
134
                                                'default'           => self::DEFAULT_PER_PAGE,
2✔
135
                                                'minimum'           => 1,
2✔
136
                                                'maximum'           => self::MAX_PER_PAGE,
2✔
137
                                                'description'       => 'The number of posts to fetch.',
2✔
138
                                                'sanitize_callback' => 'absint',
2✔
139
                                        ],
2✔
140
                                        'page'              => [
2✔
141
                                                'required'          => false,
2✔
142
                                                'type'              => 'integer',
2✔
143
                                                'default'           => 1,
2✔
144
                                                'minimum'           => 1,
2✔
145
                                                'description'       => 'The page of posts to fetch.',
2✔
146
                                                'sanitize_callback' => 'absint',
2✔
147
                                        ],
2✔
148
                                        'search'            => [
2✔
149
                                                'required'          => false,
2✔
150
                                                'type'              => 'string',
2✔
151
                                                'default'           => '',
2✔
152
                                                'description'       => 'The term to search posts by.',
2✔
153
                                                'sanitize_callback' => 'sanitize_text_field',
2✔
154
                                        ],
2✔
155
                                        'status'            => [
2✔
156
                                                'required'    => false,
2✔
157
                                                'type'        => 'array',
2✔
158
                                                'default'     => Posts_Collector_Interface::STATUSES,
2✔
159
                                                'items'       => [
2✔
160
                                                        'type' => 'string',
2✔
161
                                                        'enum' => Posts_Collector_Interface::STATUSES,
2✔
162
                                                ],
2✔
163
                                                'description' => 'The post statuses to include.',
2✔
164
                                        ],
2✔
165
                                        'needs_improvement' => [
2✔
166
                                                'required'    => false,
2✔
167
                                                'type'        => 'array',
2✔
168
                                                'default'     => [],
2✔
169
                                                'items'       => [
2✔
170
                                                        'type' => 'string',
2✔
171
                                                        'enum' => Posts_Collector_Interface::NEEDS_IMPROVEMENT_FIELDS,
2✔
172
                                                ],
2✔
173
                                                'description' => 'The fields to filter posts by; a field matches when it is empty, or (for search fields with SEO analysis enabled) when its score needs improvement.',
2✔
174
                                        ],
2✔
175
                                ],
2✔
176
                                'callback'            => [ $this, 'get_posts' ],
2✔
177
                                'permission_callback' => [ $this, 'check_permissions' ],
2✔
178
                        ],
2✔
179
                );
2✔
180
        }
181

182
        /**
183
         * Returns a page of posts for the requested content type.
184
         *
185
         * @param WP_REST_Request $request The request object.
186
         *
187
         * @return WP_REST_Response|WP_Error The posts, or an error when the content type is not editable.
188
         */
189
        public function get_posts( WP_REST_Request $request ) {
10✔
190
                $content_type = $request->get_param( 'content_type' );
10✔
191

192
                if ( ! $this->is_valid_content_type( $content_type ) ) {
10✔
193
                        return new WP_Error(
2✔
194
                                'rest_invalid_content_type',
2✔
195
                                'The requested content type is not editable.',
2✔
196
                                [ 'status' => 400 ],
2✔
197
                        );
2✔
198
                }
199

200
                // An empty selection (no status filter) means all statuses, never zero results.
201
                $statuses = (array) $request->get_param( 'status' );
8✔
202
                if ( empty( $statuses ) ) {
8✔
203
                        $statuses = Posts_Collector_Interface::STATUSES;
2✔
204
                }
205

206
                // Narrow the query to the user's own posts when they cannot edit other authors' posts. This keeps
207
                // pagination cheap; the exact per-post edit permission is still enforced when collecting the page.
208
                $author_id = null;
8✔
209
                if ( ! $this->content_type_access_checker->can_edit_others( $content_type ) ) {
8✔
210
                        $author_id = $this->user_helper->get_current_user_id();
2✔
211
                }
212

213
                // The per-field scores only back the filter while SEO analysis is on; otherwise they go stale and
214
                // the filter falls back to the empty-field check.
215
                $scores_enabled = $this->options_helper->get( 'keyword_analysis_active' ) === true;
8✔
216

217
                $query = new Posts_Query(
8✔
218
                        $content_type,
8✔
219
                        (int) $request->get_param( 'page' ),
8✔
220
                        (int) $request->get_param( 'per_page' ),
8✔
221
                        (string) $request->get_param( 'search' ),
8✔
222
                        $statuses,
8✔
223
                        $author_id,
8✔
224
                        (array) $request->get_param( 'needs_improvement' ),
8✔
225
                        $scores_enabled,
8✔
226
                );
8✔
227

228
                // Posts the current user cannot edit are returned locked and without their SEO data; the per-post
229
                // permission is resolved while collecting the page.
230
                return new WP_REST_Response( $this->posts_repository->get_posts( $query )->to_array() );
8✔
231
        }
232

233
        /**
234
         * Checks whether the current user is allowed to use the bulk editor.
235
         *
236
         * @return bool Whether the current user is allowed to use the bulk editor.
237
         */
238
        public function check_permissions(): bool {
4✔
239
                return \current_user_can( 'wpseo_manage_options' );
4✔
240
        }
241

242
        /**
243
         * Checks whether the given content type is one the bulk editor can edit.
244
         *
245
         * @param string $content_type The content type to check.
246
         *
247
         * @return bool Whether the content type is editable.
248
         */
249
        private function is_valid_content_type( string $content_type ): bool {
10✔
250
                foreach ( $this->content_types_repository->get_content_types() as $editable ) {
10✔
251
                        if ( $editable['name'] === $content_type ) {
10✔
252
                                return true;
8✔
253
                        }
254
                }
255

256
                return false;
2✔
257
        }
258
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc