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 7da61b0486868..71ef64dba7dec 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,21 @@ public function get_items( $request ) { 'form' => wp_prepare_json_value_for_client( $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. 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 && + 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'] ); + } + return rest_ensure_response( $response ); } @@ -185,6 +201,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. */ @@ -315,6 +332,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 ebf3dd3dc5fc0..e87d170ee22da 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -858,3 +858,145 @@ 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. 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 + * 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. + * @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, $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, $default_view, $default_layouts ); + 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 in every layout it can be shown in. + * + * 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 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 in some layout or its shape is not recognized. + */ +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(); + $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 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 + * + * @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(); + foreach ( $layers as $layer ) { + if ( array_key_exists( 'filters', $layer ) ) { + $filters = $layer['filters']; + } + } + + 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; + } + } + + // 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 1cb6928e5ecc9..cf46a2cbbb8f7 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'], $config['default_view'], $config['default_layouts'] ), $data['view_list'] ); $this->assertSame( $config['form'], $data['form'] ); } @@ -488,4 +530,280 @@ public function test_get_item_schema_describes_table_column_styles() { $this->assertArrayHasKey( 'description', $styles['additionalProperties']['properties'][ $property ], "The `$property` column style should be described." ); } } + + /** + * 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'] ); + } + + /** + * 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.' ); + } + + /** + * 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 ); + } }