From cb6d16580c1d1a1097d67d9dd00753fc50d3419c Mon Sep 17 00:00:00 2001 From: im3dabasia Date: Wed, 7 Oct 2026 18:03:24 +0530 Subject: [PATCH 1/3] REST API: Add item counts to the view list in the view config endpoint. --- .../class-wp-rest-view-config-controller.php | 16 +++ src/wp-includes/view-config.php | 92 ++++++++++++ .../rest-api/rest-view-config-controller.php | 135 +++++++++++++++++- 3 files changed, 241 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index da379cc6c84e2..b12e8509a7789 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -140,6 +140,7 @@ protected function get_required_capability( $kind, $name ) { * Returns the default view configuration for the given entity type. * * @since 7.1.0 + * @since 7.2.0 Added the item count of each view in the `page` view list. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -161,6 +162,15 @@ public function get_items( $request ) { 'form' => $this->cast_empty_objects( $config['form'], $schema['properties']['form'] ), ); + /* + * The counts are added here rather than through the view config filter, + * so that they are computed from the view list after every filter has + * changed it. + */ + if ( 'postType' === $kind && 'page' === $name && is_array( $response['view_list'] ) ) { + $response['view_list'] = _wp_add_counts_to_view_list( 'page', $response['view_list'] ); + } + return rest_ensure_response( $response ); } @@ -243,6 +253,7 @@ protected function cast_empty_objects( $value, $schema ) { * Retrieves the item's schema, conforming to JSON Schema. * * @since 7.1.0 + * @since 7.2.0 Added the `count` property to the view list entries. * * @return array Item schema data. */ @@ -373,6 +384,11 @@ public function get_item_schema() { $view_base_properties ), ), + 'count' => array( + 'description' => __( 'Number of items the view holds.' ), + 'type' => 'integer', + 'readonly' => true, + ), ), ), ), diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index 8765a5d095008..e739811f5c163 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -802,3 +802,95 @@ function _wp_get_entity_view_config_root_site( $data ) { 1 ); } + +/** + * Adds the item count of each view to a post type's view list. + * + * Runs on the view list as the client receives it, after every view config + * filter, so a count always describes the view it sits on. Only a view whose + * total a status count answers gets one: a view without filters, which lists + * every status but trash, and a view whose only filter is on the status. A view + * narrowed by anything else, such as a date filter a plugin added, is left + * without a count rather than showing a total it does not hold. + * + * `wp_count_posts()` answers every view from one cached query, and its + * `readable` permission argument keeps private posts the current user cannot + * read out of the totals. + * + * @since 7.2.0 + * + * @param string $post_type The post type the view list belongs to. + * @param array $view_list The view list. + * @return array The view list, with a count on each view a status total answers. + */ +function _wp_add_counts_to_view_list( $post_type, $view_list ) { + $counts = (array) wp_count_posts( $post_type, 'readable' ); + + foreach ( $view_list as $index => $entry ) { + $statuses = _wp_get_view_list_entry_statuses( $entry ); + if ( null === $statuses ) { + continue; + } + + $count = 0; + foreach ( $statuses as $status ) { + $count += isset( $counts[ $status ] ) ? (int) $counts[ $status ] : 0; + } + $view_list[ $index ]['count'] = $count; + } + + return $view_list; +} + +/** + * Returns the statuses a view list entry lists, when the status is all it + * filters by. + * + * The entry comes from view config filters, so its shape is not trusted. + * + * @since 7.2.0 + * + * @param mixed $entry A view list entry. + * @return string[]|null The statuses, or null when the view filters by anything + * else or its shape is not recognized. + */ +function _wp_get_view_list_entry_statuses( $entry ) { + if ( ! is_array( $entry ) ) { + return null; + } + + $view = isset( $entry['view'] ) ? (array) $entry['view'] : array(); + if ( ! empty( $view['search'] ) ) { + return null; + } + + $filters = isset( $view['filters'] ) ? $view['filters'] : array(); + if ( ! is_array( $filters ) ) { + return null; + } + if ( empty( $filters ) ) { + // Every status but trash, matching what a view without filters queries. + return array( 'publish', 'future', 'draft', 'pending', 'private' ); + } + if ( 1 !== count( $filters ) ) { + return null; + } + + $filter = (array) reset( $filters ); + if ( + ! isset( $filter['field'], $filter['operator'], $filter['value'] ) || + 'status' !== $filter['field'] || + ! in_array( $filter['operator'], array( 'is', 'isAny' ), true ) + ) { + return null; + } + + $statuses = is_array( $filter['value'] ) ? $filter['value'] : array( $filter['value'] ); + foreach ( $statuses as $status ) { + if ( ! is_string( $status ) ) { + return null; + } + } + + return $statuses; +} diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index c6a7ed86b816d..36d74666b6ab5 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -39,7 +39,7 @@ class WP_REST_View_Config_Controller_Test extends WP_Test_REST_TestCase { protected static $subscriber_id; /** - * Creates shared users. + * Creates shared users and the pages the view counts are made of. * * @param WP_UnitTest_Factory $factory Factory instance. */ @@ -47,6 +47,32 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + $factory->post->create_many( + 2, + array( + 'post_type' => 'page', + 'post_status' => 'draft', + ) + ); + $factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'pending', + ) + ); + $factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'trash', + ) + ); } /** @@ -76,6 +102,21 @@ private function dispatch_request( $kind = 'postType', $name = 'page' ) { return rest_get_server()->dispatch( $request ); } + /** + * Returns the `page` view list from the view-config route, keyed by slug. + * + * @return array The view list entries, keyed by slug. + */ + private function get_page_view_list() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request( 'postType', 'page' ); + $this->assertSame( 200, $response->get_status() ); + + $data = json_decode( wp_json_encode( $response->get_data() ), true ); + return array_column( $data['view_list'], null, 'slug' ); + } + /** * The route is registered. * @@ -298,7 +339,8 @@ public function test_get_items_matches_underlying_config() { $this->assertSame( $config['default_view'], $data['default_view'] ); $this->assertSame( $config['default_layouts'], $data['default_layouts'] ); - $this->assertSame( $config['view_list'], $data['view_list'] ); + // The controller adds the item counts to the `page` view list. + $this->assertSame( _wp_add_counts_to_view_list( 'page', $config['view_list'] ), $data['view_list'] ); $this->assertSame( $config['form'], $data['form'] ); } @@ -439,4 +481,93 @@ public function test_get_item_schema_excludes_url_managed_view_properties() { $this->assertArrayNotHasKey( 'page', $properties, "$label should not declare a `page` property." ); } } + + /** + * Each status view of the `page` view list counts the pages in its status, + * and the "All" view counts every status but trash. + * + * @covers ::get_items + */ + public function test_get_items_counts_page_status_views() { + $view_list = $this->get_page_view_list(); + + $this->assertSame( 4, $view_list['all']['count'] ); + $this->assertSame( 1, $view_list['published']['count'] ); + $this->assertSame( 0, $view_list['future']['count'] ); + $this->assertSame( 2, $view_list['drafts']['count'] ); + $this->assertSame( 1, $view_list['pending']['count'] ); + $this->assertSame( 0, $view_list['private']['count'] ); + $this->assertSame( 1, $view_list['trash']['count'] ); + } + + /** + * A view narrowed by more than its status gets no count, since a status + * total would not describe it. + * + * @covers ::get_items + */ + public function test_get_items_omits_count_of_view_narrowed_by_other_filters() { + $narrow_drafts = static function ( $data ) { + return $data->merge( + array( + 'view_list' => array( + array( + 'slug' => 'drafts', + 'view' => array( + 'filters' => array( + array( + 'field' => 'date', + 'operator' => 'after', + 'value' => '2018-01-01T00:00:00', + ), + ), + ), + ), + ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $narrow_drafts ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $narrow_drafts ); + + $this->assertArrayNotHasKey( 'count', $view_list['drafts'] ); + $this->assertSame( 1, $view_list['published']['count'], 'Views the filter leaves alone keep their count.' ); + } + + /** + * A view a plugin adds gets a count when the status is all it filters by. + * + * @covers ::get_items + */ + public function test_get_items_counts_added_status_only_view() { + $add_unpublished = static function ( $data ) { + return $data->merge( + array( + 'view_list' => array( + array( + 'slug' => 'unpublished', + 'title' => 'Unpublished', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => array( 'draft', 'pending' ), + ), + ), + ), + ), + ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $add_unpublished ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $add_unpublished ); + + $this->assertSame( 3, $view_list['unpublished']['count'] ); + } } From 3a36b350167a43ba78c10dd6d491c175be839e38 Mon Sep 17 00:00:00 2001 From: im3dabasia Date: Tue, 20 Oct 2026 14:14:15 +0530 Subject: [PATCH 2/3] REST API: Count views only when their inherited and per-layout filters are status-only --- .../class-wp-rest-view-config-controller.php | 2 +- src/wp-includes/view-config.php | 82 +++++++++++--- .../rest-api/rest-view-config-controller.php | 103 +++++++++++++++++- 3 files changed, 170 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index b12e8509a7789..c822e6666fc89 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -168,7 +168,7 @@ public function get_items( $request ) { * changed it. */ if ( 'postType' === $kind && 'page' === $name && is_array( $response['view_list'] ) ) { - $response['view_list'] = _wp_add_counts_to_view_list( 'page', $response['view_list'] ); + $response['view_list'] = _wp_add_counts_to_view_list( 'page', $response['view_list'], $response['default_view'], $response['default_layouts'] ); } return rest_ensure_response( $response ); diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index e739811f5c163..24fffe3ae6e44 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -811,7 +811,9 @@ function _wp_get_entity_view_config_root_site( $data ) { * total a status count answers gets one: a view without filters, which lists * every status but trash, and a view whose only filter is on the status. A view * narrowed by anything else, such as a date filter a plugin added, is left - * without a count rather than showing a total it does not hold. + * without a count rather than showing a total it does not hold. That includes + * filters a view inherits from the default view or from the defaults of any + * layout it can be shown in. * * `wp_count_posts()` answers every view from one cached query, and its * `readable` permission argument keeps private posts the current user cannot @@ -819,15 +821,17 @@ function _wp_get_entity_view_config_root_site( $data ) { * * @since 7.2.0 * - * @param string $post_type The post type the view list belongs to. - * @param array $view_list The view list. + * @param string $post_type The post type the view list belongs to. + * @param array $view_list The view list. + * @param array|object $default_view The default view the views inherit from. + * @param array|object $default_layouts The default view properties of each layout. * @return array The view list, with a count on each view a status total answers. */ -function _wp_add_counts_to_view_list( $post_type, $view_list ) { +function _wp_add_counts_to_view_list( $post_type, $view_list, $default_view = array(), $default_layouts = array() ) { $counts = (array) wp_count_posts( $post_type, 'readable' ); foreach ( $view_list as $index => $entry ) { - $statuses = _wp_get_view_list_entry_statuses( $entry ); + $statuses = _wp_get_view_list_entry_statuses( $entry, $default_view, $default_layouts ); if ( null === $statuses ) { continue; } @@ -844,28 +848,76 @@ function _wp_add_counts_to_view_list( $post_type, $view_list ) { /** * Returns the statuses a view list entry lists, when the status is all it - * filters by. + * filters by in every layout it can be shown in. * - * The entry comes from view config filters, so its shape is not trusted. + * The user can switch a view to any layout on offer, and each layout's defaults + * can bring filters of their own. The server cannot see which layout the user + * picked, so a count has to hold in all of them: when the layouts disagree, the + * entry gets none. + * + * The entry and defaults come from view config filters, so their shape is not + * trusted. * * @since 7.2.0 * - * @param mixed $entry A view list entry. + * @param mixed $entry A view list entry. + * @param array|object $default_view The default view the entry inherits from. + * @param array|object $default_layouts The default view properties of each layout. * @return string[]|null The statuses, or null when the view filters by anything - * else or its shape is not recognized. + * else in some layout or its shape is not recognized. */ -function _wp_get_view_list_entry_statuses( $entry ) { +function _wp_get_view_list_entry_statuses( $entry, $default_view = array(), $default_layouts = array() ) { if ( ! is_array( $entry ) ) { return null; } - $view = isset( $entry['view'] ) ? (array) $entry['view'] : array(); - if ( ! empty( $view['search'] ) ) { - return null; + $view = isset( $entry['view'] ) ? (array) $entry['view'] : array(); + $default_view = (array) $default_view; + $default_layouts = (array) $default_layouts; + + // Without layouts on offer, no layout defaults apply. + $layouts = empty( $default_layouts ) ? array( array() ) : $default_layouts; + + $statuses = null; + foreach ( $layouts as $layout_defaults ) { + $layout_statuses = _wp_get_layered_view_statuses( + array( $default_view, (array) $layout_defaults, $view ) + ); + if ( null === $layout_statuses || ( null !== $statuses && $layout_statuses !== $statuses ) ) { + return null; + } + $statuses = $layout_statuses; + } + + return $statuses; +} + +/** + * Returns the statuses a view lists, when the status is all it filters by. + * + * Resolves the view's filters and search out of its layers as the client does: + * a later layer that sets either one replaces it outright rather than adding to + * it. + * + * @since 7.2.0 + * + * @param array[] $layers The view's layers, lowest first. + * @return string[]|null The statuses, or null when the view filters by anything + * else or its shape is not recognized. + */ +function _wp_get_layered_view_statuses( $layers ) { + $filters = array(); + $search = ''; + foreach ( $layers as $layer ) { + if ( array_key_exists( 'filters', $layer ) ) { + $filters = $layer['filters']; + } + if ( array_key_exists( 'search', $layer ) ) { + $search = $layer['search']; + } } - $filters = isset( $view['filters'] ) ? $view['filters'] : array(); - if ( ! is_array( $filters ) ) { + if ( ! empty( $search ) || ! is_array( $filters ) ) { return null; } if ( empty( $filters ) ) { diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index 36d74666b6ab5..cedd477234e87 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -340,7 +340,7 @@ public function test_get_items_matches_underlying_config() { $this->assertSame( $config['default_view'], $data['default_view'] ); $this->assertSame( $config['default_layouts'], $data['default_layouts'] ); // The controller adds the item counts to the `page` view list. - $this->assertSame( _wp_add_counts_to_view_list( 'page', $config['view_list'] ), $data['view_list'] ); + $this->assertSame( _wp_add_counts_to_view_list( 'page', $config['view_list'], $config['default_view'], $config['default_layouts'] ), $data['view_list'] ); $this->assertSame( $config['form'], $data['form'] ); } @@ -570,4 +570,105 @@ public function test_get_items_counts_added_status_only_view() { $this->assertSame( 3, $view_list['unpublished']['count'] ); } + + /** + * A view without filters of its own inherits those of the default view, so + * a filter there leaves the "All" view without a count. The status views + * set their own filters, which replace the inherited ones, so they keep + * theirs. + * + * @covers ::get_items + */ + public function test_get_items_omits_count_of_view_inheriting_default_view_filters() { + $filter_default_view = static function ( $data ) { + return $data->merge( + array( + 'default_view' => array( + 'filters' => array( + array( + 'field' => 'author', + 'operator' => 'isAny', + 'value' => array( 1 ), + ), + ), + ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $filter_default_view ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $filter_default_view ); + + $this->assertArrayNotHasKey( 'count', $view_list['all'] ); + $this->assertSame( 2, $view_list['drafts']['count'] ); + } + + /** + * A view without filters of its own also inherits those of its layout's + * defaults, which take precedence over the default view's. + * + * @covers ::get_items + */ + public function test_get_items_omits_count_of_view_inheriting_layout_filters() { + $filter_table_layout = static function ( $data ) { + return $data->merge( + array( + 'default_view' => array( 'type' => 'table' ), + 'default_layouts' => array( + 'table' => array( + 'filters' => array( + array( + 'field' => 'author', + 'operator' => 'isAny', + 'value' => array( 1 ), + ), + ), + ), + ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $filter_table_layout ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $filter_table_layout ); + + $this->assertArrayNotHasKey( 'count', $view_list['all'] ); + $this->assertSame( 2, $view_list['drafts']['count'] ); + } + + /** + * The user can switch a view to any layout on offer, which the server cannot + * see, so a filter in the defaults of a layout other than the default one + * also leaves the "All" view without a count. + * + * @covers ::get_items + */ + public function test_get_items_omits_count_when_another_layout_has_filters() { + $filter_table_layout = static function ( $data ) { + return $data->merge( + array( + 'default_layouts' => array( + 'table' => array( + 'filters' => array( + array( + 'field' => 'author', + 'operator' => 'isAny', + 'value' => array( 1 ), + ), + ), + ), + ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $filter_table_layout ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $filter_table_layout ); + + $this->assertArrayNotHasKey( 'count', $view_list['all'] ); + $this->assertSame( 2, $view_list['drafts']['count'], 'Views with filters of their own keep their count in every layout.' ); + } } From 08780120524b38b6c89968ccc2791138631c5fa0 Mon Sep 17 00:00:00 2001 From: im3dabasia Date: Wed, 21 Oct 2026 12:23:57 +0530 Subject: [PATCH 3/3] REST API: Ignore config search, count repeated statuses once and skip counts for partial view config requests --- .../class-wp-rest-view-config-controller.php | 10 ++- src/wp-includes/view-config.php | 16 ++-- .../rest-api/rest-view-config-controller.php | 86 +++++++++++++++++++ 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index ca7f085574288..16839be58932c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -165,9 +165,15 @@ public function get_items( $request ) { /* * The counts are added here rather than through the view config filter, * so that they are computed from the view list after every filter has - * changed it. + * changed it. The counting is skipped when the request leaves out the + * view list, such as the editor's request for the form alone. */ - if ( 'postType' === $kind && 'page' === $name && is_array( $response['view_list'] ) ) { + if ( + 'postType' === $kind && + 'page' === $name && + rest_is_field_included( 'view_list', $this->get_fields_for_response( $request ) ) && + is_array( $response['view_list'] ) + ) { $response['view_list'] = _wp_add_counts_to_view_list( 'page', $response['view_list'], $response['default_view'], $response['default_layouts'] ); } diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index 24a1e870605cf..e87d170ee22da 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -951,9 +951,10 @@ function _wp_get_view_list_entry_statuses( $entry, $default_view = array(), $def /** * Returns the statuses a view lists, when the status is all it filters by. * - * Resolves the view's filters and search out of its layers as the client does: - * a later layer that sets either one replaces it outright rather than adding to - * it. + * Resolves the view's filters out of its layers as the client does: a later + * layer that sets them replaces them outright rather than adding to them. A + * `search` in a layer is ignored, as the client drops it from the config and + * takes the search from the URL instead. * * @since 7.2.0 * @@ -963,17 +964,13 @@ function _wp_get_view_list_entry_statuses( $entry, $default_view = array(), $def */ function _wp_get_layered_view_statuses( $layers ) { $filters = array(); - $search = ''; foreach ( $layers as $layer ) { if ( array_key_exists( 'filters', $layer ) ) { $filters = $layer['filters']; } - if ( array_key_exists( 'search', $layer ) ) { - $search = $layer['search']; - } } - if ( ! empty( $search ) || ! is_array( $filters ) ) { + if ( ! is_array( $filters ) ) { return null; } if ( empty( $filters ) ) { @@ -1000,5 +997,6 @@ function _wp_get_layered_view_statuses( $layers ) { } } - return $statuses; + // A status listed twice is still queried once. + return array_values( array_unique( $statuses ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index 7638f2036540e..99a45f41ce9ee 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -699,4 +699,90 @@ public function test_get_items_omits_count_when_another_layout_has_filters() { $this->assertArrayNotHasKey( 'count', $view_list['all'] ); $this->assertSame( 2, $view_list['drafts']['count'], 'Views with filters of their own keep their count in every layout.' ); } + + /** + * A search in the config does not narrow the views: the client drops it and + * takes the search from the URL instead. + * + * @covers ::get_items + */ + public function test_get_items_counts_views_despite_a_search_in_the_config() { + $search_default_view = static function ( $data ) { + return $data->merge( + array( + 'default_view' => array( 'search' => 'hello' ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $search_default_view ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $search_default_view ); + + $this->assertSame( 4, $view_list['all']['count'] ); + $this->assertSame( 2, $view_list['drafts']['count'] ); + } + + /** + * A status listed twice in a view's filter is counted once, as the query + * lists it once. + * + * @covers ::get_items + */ + public function test_get_items_counts_a_repeated_status_once() { + $add_repeated = static function ( $data ) { + return $data->merge( + array( + 'view_list' => array( + array( + 'slug' => 'repeated', + 'title' => 'Repeated', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => array( 'draft', 'draft', 'pending' ), + ), + ), + ), + ), + ), + ), + 1 + ); + }; + add_filter( 'get_entity_view_config_posttype_page', $add_repeated ); + $view_list = $this->get_page_view_list(); + remove_filter( 'get_entity_view_config_posttype_page', $add_repeated ); + + $this->assertSame( 3, $view_list['repeated']['count'] ); + } + + /** + * The counts are not computed when the request leaves out the view list. + * + * @covers ::get_items + */ + public function test_get_items_skips_counts_when_view_list_is_not_requested() { + wp_set_current_user( self::$editor_id ); + + $count_calls = 0; + $count_posts = static function ( $counts ) use ( &$count_calls ) { + ++$count_calls; + return $counts; + }; + add_filter( 'wp_count_posts', $count_posts ); + + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'kind', 'postType' ); + $request->set_param( 'name', 'page' ); + $request->set_param( '_fields', 'form' ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'wp_count_posts', $count_posts ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 0, $count_calls ); + } }