From 508083e78ae3250ca978612180f0901e884163ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:45:04 +0200 Subject: [PATCH 01/12] Editor: Backport the View Config REST API from Gutenberg. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a server-side, filterable API for the default DataViews view configuration of an entity, plus a REST endpoint that exposes it. - `wp_get_entity_view_config( $kind, $name )` (new src/wp-includes/view-config.php) builds the shared defaults (default view, layouts, view list, form) and runs them through a dynamic `get_entity_view_config_{$kind}_{$name}` filter so core and plugins can provide per-entity configuration. - Core registers default providers for the `page`, `post`, `wp_block`, `wp_template_part`, and `wp_template` post types as filter callbacks on `init` (`_wp_get_entity_view_config_post_type_*`). - `WP_REST_View_Config_Controller` exposes the config at `GET /wp/v2/view-config?kind=…&name=…`, delegating to the API and handling REST concerns (schema, `edit_posts` permission, empty-object serialization). - Adds PHPUnit coverage for both the API and the controller. Backports the following Gutenberg pull requests: * https://github.com/WordPress/gutenberg/pull/76573 * https://github.com/WordPress/gutenberg/pull/76734 * https://github.com/WordPress/gutenberg/pull/76622 * https://github.com/WordPress/gutenberg/pull/76823 * https://github.com/WordPress/gutenberg/pull/76953 * https://github.com/WordPress/gutenberg/pull/76903 * https://github.com/WordPress/gutenberg/pull/77290 * https://github.com/WordPress/gutenberg/pull/78977 * https://github.com/WordPress/gutenberg/pull/79195 * https://github.com/WordPress/gutenberg/pull/76934 * https://github.com/WordPress/gutenberg/pull/79347 AI assistance: Yes Tool(s): Claude Code Model(s): Claude Opus 4.8 Used for: Porting the code and tests from Gutenberg and adapting them to core conventions; all changes reviewed and tested by me. --- src/wp-includes/rest-api.php | 4 + .../class-wp-rest-view-config-controller.php | 728 ++++++++++++++++ src/wp-includes/view-config.php | 793 ++++++++++++++++++ src/wp-settings.php | 2 + .../rest-api/rest-view-config-controller.php | 213 +++++ tests/phpunit/tests/view-config.php | 226 +++++ 6 files changed, 1966 insertions(+) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php create mode 100644 src/wp-includes/view-config.php create mode 100644 tests/phpunit/tests/rest-api/rest-view-config-controller.php create mode 100644 tests/phpunit/tests/view-config.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a4c22e8f1cca1..e81de0ab281ee 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -428,6 +428,10 @@ function create_initial_rest_routes() { // Icons. $icons_controller = new WP_REST_Icons_Controller(); $icons_controller->register_routes(); + + // View Config. + $view_config_controller = new WP_REST_View_Config_Controller(); + $view_config_controller->register_routes(); } /** 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 new file mode 100644 index 0000000000000..4c544c73d903f --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -0,0 +1,728 @@ +namespace = 'wp/v2'; + $this->rest_base = 'view-config'; + } + + /** + * Registers the routes for the controller. + * + * @since 7.1.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => array( + 'kind' => array( + 'description' => __( 'Entity kind.' ), + 'type' => 'string', + 'required' => true, + ), + 'name' => array( + 'description' => __( 'Entity name.' ), + 'type' => 'string', + 'required' => true, + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read view config. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read view config.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Returns the default view configuration for the given entity type. + * + * @since 7.1.0 + * + * @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. + */ + public function get_items( $request ) { + $kind = $request->get_param( 'kind' ); + $name = $request->get_param( 'name' ); + + $config = wp_get_entity_view_config( $kind, $name ); + + /* + * The schema types these as objects, but PHP encodes empty arrays as + * JSON arrays ([]). Cast the object-typed values that may be empty so + * they serialize as JSON objects ({}) and match the schema. + */ + $default_view = $config['default_view']; + if ( isset( $default_view['layout'] ) ) { + $default_view['layout'] = (object) $default_view['layout']; + } + + $default_layouts = $config['default_layouts']; + foreach ( $default_layouts as $view_type => $layout ) { + if ( isset( $layout['layout'] ) ) { + $layout['layout'] = (object) $layout['layout']; + } + $default_layouts[ $view_type ] = (object) $layout; + } + + $response = array( + 'kind' => $kind, + 'name' => $name, + 'default_view' => $default_view, + 'default_layouts' => $default_layouts, + 'view_list' => $config['view_list'], + 'form' => (object) $config['form'], + ); + + return rest_ensure_response( $response ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $view_base_properties = $this->get_view_base_schema(); + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'view-config', + 'type' => 'object', + 'properties' => array( + 'kind' => array( + 'description' => __( 'Entity kind.' ), + 'type' => 'string', + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Entity name.' ), + 'type' => 'string', + 'readonly' => true, + ), + 'default_view' => array( + 'description' => __( 'Default view configuration.' ), + 'type' => 'object', + 'readonly' => true, + 'properties' => array_merge( + array( + 'type' => array( + 'type' => 'string', + ), + 'layout' => $this->get_combined_layout_schema(), + ), + $view_base_properties + ), + ), + 'default_layouts' => array( + 'description' => __( 'Default layout configurations.' ), + 'type' => 'object', + 'readonly' => true, + 'properties' => array( + 'table' => array( + 'type' => 'object', + 'properties' => array_merge( + $view_base_properties, + array( + 'layout' => $this->get_table_layout_schema(), + ) + ), + ), + 'list' => array( + 'type' => 'object', + 'properties' => array_merge( + $view_base_properties, + array( + 'layout' => $this->get_list_layout_schema(), + ) + ), + ), + 'grid' => array( + 'type' => 'object', + 'properties' => array_merge( + $view_base_properties, + array( + 'layout' => $this->get_grid_layout_schema(), + ) + ), + ), + 'activity' => array( + 'type' => 'object', + 'properties' => array_merge( + $view_base_properties, + array( + 'layout' => $this->get_list_layout_schema(), + ) + ), + ), + 'pickerGrid' => array( + 'type' => 'object', + 'properties' => array_merge( + $view_base_properties, + array( + 'layout' => $this->get_grid_layout_schema(), + ) + ), + ), + 'pickerTable' => array( + 'type' => 'object', + 'properties' => array_merge( + $view_base_properties, + array( + 'layout' => $this->get_table_layout_schema(), + ) + ), + ), + ), + ), + 'view_list' => array( + 'description' => __( 'List of default views.' ), + 'type' => 'array', + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + ), + 'slug' => array( + 'type' => 'string', + ), + 'view' => array( + 'type' => 'object', + 'properties' => array_merge( + array( + 'type' => array( + 'type' => 'string', + ), + 'layout' => $this->get_combined_layout_schema(), + ), + $view_base_properties + ), + ), + ), + ), + ), + 'form' => array( + 'description' => __( 'Default form configuration.' ), + 'type' => 'object', + 'readonly' => true, + 'properties' => $this->get_form_schema(), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Returns the schema properties shared by all view types (ViewBase), excluding 'type'. + * + * @since 7.1.0 + * + * @return array Schema properties for the base view configuration. + */ + private function get_view_base_schema() { + return array( + 'search' => array( + 'type' => 'string', + ), + 'filters' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'type' => 'string', + ), + 'operator' => array( + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array(), + 'isLocked' => array( + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'type' => 'string', + ), + 'direction' => array( + 'type' => 'string', + 'enum' => array( 'asc', 'desc' ), + ), + ), + ), + 'page' => array( + 'type' => 'integer', + ), + 'perPage' => array( + 'type' => 'integer', + ), + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'type' => 'string', + ), + 'mediaField' => array( + 'type' => 'string', + ), + 'descriptionField' => array( + 'type' => 'string', + ), + 'showTitle' => array( + 'type' => 'boolean', + ), + 'showMedia' => array( + 'type' => 'boolean', + ), + 'showDescription' => array( + 'type' => 'boolean', + ), + 'showLevels' => array( + 'type' => 'boolean', + ), + 'groupBy' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'type' => 'string', + ), + 'direction' => array( + 'type' => 'string', + 'enum' => array( 'asc', 'desc' ), + ), + 'showLabel' => array( + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'type' => 'boolean', + ), + ); + } + + /** + * Returns the schema for the ColumnStyle type. + * + * @since 7.1.0 + * + * @return array Schema for a column style object. + */ + private function get_column_style_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'width' => array( + 'type' => array( 'string', 'number' ), + ), + 'maxWidth' => array( + 'type' => array( 'string', 'number' ), + ), + 'minWidth' => array( + 'type' => array( 'string', 'number' ), + ), + 'align' => array( + 'type' => 'string', + 'enum' => array( 'start', 'center', 'end' ), + ), + ), + ); + } + + /** + * Returns the layout schema for table-type views (ViewTable, ViewPickerTable). + * + * @since 7.1.0 + * + * @return array Schema for a table layout object. + */ + private function get_table_layout_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'styles' => array( + 'type' => 'object', + 'additionalProperties' => $this->get_column_style_schema(), + ), + 'density' => array( + 'type' => 'string', + 'enum' => array( 'compact', 'balanced', 'comfortable' ), + ), + 'enableMoving' => array( + 'type' => 'boolean', + ), + ), + ); + } + + /** + * Returns the layout schema for list-type views (ViewList, ViewActivity). + * + * @since 7.1.0 + * + * @return array Schema for a list layout object. + */ + private function get_list_layout_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'density' => array( + 'type' => 'string', + 'enum' => array( 'compact', 'balanced', 'comfortable' ), + ), + ), + ); + } + + /** + * Returns a combined layout schema that accepts properties from all view types. + * + * This is useful for contexts where the view type is not known ahead of time + * (e.g. the `view` override in a view list item), so all possible layout + * properties must be accepted. + * + * @since 7.1.0 + * + * @return array Schema for a combined layout object. + */ + private function get_combined_layout_schema() { + return array( + 'type' => 'object', + 'properties' => array_merge( + $this->get_table_layout_schema()['properties'], + $this->get_grid_layout_schema()['properties'], + $this->get_list_layout_schema()['properties'] + ), + ); + } + + /** + * Returns the layout schema for grid-type views (ViewGrid, ViewPickerGrid). + * + * @since 7.1.0 + * + * @return array Schema for a grid layout object. + */ + private function get_grid_layout_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'badgeFields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'previewSize' => array( + 'type' => 'number', + ), + 'density' => array( + 'type' => 'string', + 'enum' => array( 'compact', 'balanced', 'comfortable' ), + ), + ), + ); + } + + /** + * Returns the schema for a form layout object as a discriminated union. + * + * Each variant is discriminated by a single-value enum on its `type` property, + * matching the TypeScript Layout union in dataviews/src/types/dataform.ts. + * + * @since 7.1.0 + * + * @return array Schema for a form layout object. + */ + private function get_form_layout_schema() { + return array( + 'oneOf' => array( + // RegularLayout. + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'regular' ), + ), + 'labelPosition' => array( + 'type' => 'string', + 'enum' => array( 'top', 'side', 'none' ), + ), + ), + ), + // PanelLayout. + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'panel' ), + ), + 'labelPosition' => array( + 'type' => 'string', + 'enum' => array( 'top', 'side', 'none' ), + ), + 'openAs' => array( + 'oneOf' => array( + array( + 'type' => 'string', + 'enum' => array( 'dropdown', 'modal' ), + ), + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'dropdown', 'modal' ), + ), + 'applyLabel' => array( + 'type' => 'string', + ), + 'cancelLabel' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + 'summary' => array( + 'oneOf' => array( + array( 'type' => 'string' ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + 'editVisibility' => array( + 'type' => 'string', + 'enum' => array( 'always', 'on-hover' ), + ), + ), + ), + // CardLayout. + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'card' ), + ), + 'withHeader' => array( + 'type' => 'boolean', + ), + 'isOpened' => array( + 'type' => 'boolean', + ), + 'isCollapsible' => array( + 'type' => 'boolean', + ), + 'summary' => array( + 'oneOf' => array( + array( 'type' => 'string' ), + array( + 'type' => 'array', + 'items' => array( + 'oneOf' => array( + array( 'type' => 'string' ), + array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + ), + 'visibility' => array( + 'type' => 'string', + 'enum' => array( 'always', 'when-collapsed' ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + // RowLayout. + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'row' ), + ), + 'alignment' => array( + 'type' => 'string', + 'enum' => array( 'start', 'center', 'end' ), + ), + 'styles' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'flex' => array( + 'type' => array( 'string', 'number' ), + ), + ), + ), + ), + ), + ), + // DetailsLayout. + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'details' ), + ), + 'summary' => array( + 'type' => 'string', + ), + ), + ), + ), + ); + } + + /** + * Returns the schema for a form field item (string or object). + * + * @since 7.1.0 + * + * @return array Schema for a form field. + */ + private function get_form_field_schema() { + return array( + 'oneOf' => array( + array( 'type' => 'string' ), + array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + ), + 'label' => array( + 'type' => 'string', + ), + 'description' => array( + 'type' => 'string', + ), + 'layout' => $this->get_form_layout_schema(), + 'children' => array( + 'type' => 'array', + 'items' => array( + 'oneOf' => array( + array( 'type' => 'string' ), + // This object can have the shape of a form field itself, + // allowing for recursive nesting of form fields. + // There's no easy way to codify this recursion via the JSON Schema draft-04 + // supported by the REST API. + array( 'type' => 'object' ), + ), + ), + ), + ), + ), + ), + ); + } + + /** + * Returns the schema for the form configuration object. + * + * @since 7.1.0 + * + * @return array Schema properties for the form configuration. + */ + private function get_form_schema() { + return array( + 'layout' => $this->get_form_layout_schema(), + 'fields' => array( + 'type' => 'array', + 'items' => $this->get_form_field_schema(), + ), + ); + } +} diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php new file mode 100644 index 0000000000000..74d730096e42e --- /dev/null +++ b/src/wp-includes/view-config.php @@ -0,0 +1,793 @@ + 'table', + 'filters' => array(), + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + 'perPage' => 20, + 'fields' => array( 'author', 'status' ), + 'titleField' => 'title', + ); + $default_layouts = array( + 'table' => array(), + 'grid' => array(), + 'list' => array(), + ); + $all_items_title = __( 'All items' ); + if ( 'postType' === $kind ) { + $post_type_object = get_post_type_object( $name ); + if ( $post_type_object && ! empty( $post_type_object->labels->all_items ) ) { + $all_items_title = $post_type_object->labels->all_items; + } + } + $view_list = array( + array( + 'title' => $all_items_title, + 'slug' => 'all', + ), + ); + + $config = array( + 'default_view' => $default_view, + 'default_layouts' => $default_layouts, + 'view_list' => $view_list, + 'form' => array(), + ); + + /** + * Filters the view configuration for a given entity. + * + * The dynamic portions of the hook name, `$kind` and `$name`, refer to the + * entity kind (e.g. `postType`) and the entity name (e.g. `page`). + * + * @since 7.1.0 + * + * @param array $config { + * The view configuration for the entity. + * + * @type array $default_view Default view configuration. + * @type array $default_layouts Default layouts configuration. + * @type array $view_list List of available views. + * @type array $form Form configuration. + * } + * @param array $entity { + * The entity the configuration is built for. + * + * @type string $kind The entity kind. + * @type string $name The entity name. + * } + */ + $filtered_config = apply_filters( + "get_entity_view_config_{$kind}_{$name}", + $config, + array( + 'kind' => $kind, + 'name' => $name, + ) + ); + + if ( ! is_array( $filtered_config ) ) { + return $config; + } + + // Backfill any dropped keys with their defaults, then discard any keys the + // filter introduced that are not part of the documented configuration shape. + $filtered_config = array_merge( $config, $filtered_config ); + return array_intersect_key( $filtered_config, $config ); +} + +/** + * Provides the view configuration for the `page` post type. + * + * @since 7.1.0 + * + * @param array $config { + * The view configuration for the entity. + * } + * @return array The filtered view configuration. + */ +function _wp_get_entity_view_config_post_type_page( $config ) { + $config['default_layouts'] = array( + 'table' => array( + 'layout' => array( + 'styles' => array( + 'author' => array( + 'align' => 'start', + ), + ), + ), + ), + 'grid' => array(), + 'list' => array(), + ); + + $config['default_view'] = array( + 'type' => 'list', + 'filters' => array(), + 'perPage' => 20, + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + 'showLevels' => true, + 'titleField' => 'title', + 'mediaField' => 'featured_media', + 'fields' => array( 'author', 'status' ), + ); + + $config['view_list'] = array( + // Reuse the base "all items" view, whose title is derived from the post + // type's `all_items` label in wp_get_entity_view_config(). + $config['view_list'][0], + array( + 'title' => __( 'Published' ), + 'slug' => 'published', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => 'publish', + 'isLocked' => true, + ), + ), + ), + ), + array( + 'title' => __( 'Scheduled' ), + 'slug' => 'future', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => 'future', + 'isLocked' => true, + ), + ), + ), + ), + array( + 'title' => __( 'Drafts' ), + 'slug' => 'drafts', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => 'draft', + 'isLocked' => true, + ), + ), + ), + ), + array( + 'title' => __( 'Pending' ), + 'slug' => 'pending', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => 'pending', + 'isLocked' => true, + ), + ), + ), + ), + array( + 'title' => __( 'Private' ), + 'slug' => 'private', + 'view' => array( + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => 'private', + 'isLocked' => true, + ), + ), + ), + ), + array( + 'title' => __( 'Trash' ), + 'slug' => 'trash', + 'view' => array( + 'type' => 'table', + 'layout' => $config['default_layouts']['table']['layout'], + 'filters' => array( + array( + 'field' => 'status', + 'operator' => 'isAny', + 'value' => 'trash', + 'isLocked' => true, + ), + ), + ), + ), + ); + + $config['form'] = array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'featured_media', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + array( + 'id' => 'post-content-info', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + array( + 'id' => 'excerpt', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'top', + ), + ), + array( + 'id' => 'status', + 'label' => __( 'Status' ), + 'children' => array( + array( + 'id' => 'status', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + 'scheduled_date', + 'password', + 'sticky', + ), + ), + 'date', + 'slug', + 'author', + 'template', + array( + 'id' => 'discussion', + 'label' => __( 'Discussion' ), + 'children' => array( + array( + 'id' => 'comment_status', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + 'ping_status', + ), + ), + 'parent', + 'format', + 'revisions', + ), + ); + + return $config; +} + +/** + * Provides the view configuration for the `post` post type. + * + * @since 7.1.0 + * + * @param array $config { + * The view configuration for the entity. + * } + * @return array The filtered view configuration. + */ +function _wp_get_entity_view_config_post_type_post( $config ) { + $config['form'] = array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'featured_media', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + array( + 'id' => 'post-content-info', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + array( + 'id' => 'excerpt', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'top', + ), + ), + array( + 'id' => 'status', + 'label' => __( 'Status' ), + 'children' => array( + array( + 'id' => 'status', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + 'scheduled_date', + 'password', + 'sticky', + ), + ), + 'date', + 'slug', + 'author', + 'template', + array( + 'id' => 'discussion', + 'label' => __( 'Discussion' ), + 'children' => array( + array( + 'id' => 'comment_status', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + 'ping_status', + ), + ), + 'parent', + 'format', + 'revisions', + ), + ); + + return $config; +} + +/** + * Provides the view configuration for the `wp_block` post type. + * + * @since 7.1.0 + * + * @param array $config { + * The view configuration for the entity. + * } + * @return array The filtered view configuration. + */ +function _wp_get_entity_view_config_post_type_wp_block( $config ) { + $config['default_layouts'] = array( + 'table' => array( + 'layout' => array( + 'styles' => array( + 'author' => array( + 'width' => '1%', + ), + ), + ), + ), + 'grid' => array( + 'layout' => array( + 'badgeFields' => array( 'sync-status' ), + ), + ), + ); + + $config['default_view'] = array( + 'type' => 'grid', + 'perPage' => 20, + 'titleField' => 'title', + 'mediaField' => 'preview', + 'fields' => array( 'sync-status' ), + 'filters' => array(), + 'layout' => $config['default_layouts']['grid']['layout'], + ); + + $view_list = array( + array( + 'title' => __( 'All patterns' ), + 'slug' => 'all-patterns', + ), + array( + 'title' => __( 'My patterns' ), + 'slug' => 'my-patterns', + ), + ); + + // Gather categories from the block pattern categories registry. + $registry = WP_Block_Pattern_Categories_Registry::get_instance(); + $categories = array(); + + foreach ( $registry->get_all_registered() as $category ) { + $categories[ $category['name'] ] = $category['label']; + } + + // Ensure "Uncategorized" is always included for patterns + // that have no category assigned. + $categories['uncategorized'] ??= __( 'Uncategorized' ); + + // Also gather user-created pattern categories (wp_pattern_category taxonomy). + $user_terms = get_terms( + array( + 'taxonomy' => 'wp_pattern_category', + 'hide_empty' => false, + ) + ); + + if ( ! is_wp_error( $user_terms ) ) { + foreach ( $user_terms as $term ) { + $categories[ $term->slug ] = $term->name; + } + } + + // Sort categories alphabetically by label. + asort( $categories, SORT_NATURAL | SORT_FLAG_CASE ); + + foreach ( $categories as $category_name => $label ) { + $view_list[] = array( + 'title' => $label, + 'slug' => $category_name, + ); + } + + $config['view_list'] = $view_list; + + return $config; +} + +/** + * Provides the view configuration for the `wp_template_part` post type. + * + * @since 7.1.0 + * + * @param array $config { + * The view configuration for the entity. + * } + * @return array The filtered view configuration. + */ +function _wp_get_entity_view_config_post_type_wp_template_part( $config ) { + $config['default_layouts'] = array( + 'table' => array( + 'layout' => array( + 'styles' => array( + 'author' => array( + 'width' => '1%', + ), + ), + ), + ), + 'grid' => array( + 'layout' => array(), + ), + ); + + $config['default_view'] = array( + 'type' => 'grid', + 'perPage' => 20, + 'titleField' => 'title', + 'mediaField' => 'preview', + 'fields' => array( 'author' ), + 'filters' => array(), + 'layout' => $config['default_layouts']['grid']['layout'], + ); + + $view_list = array( + array( + 'title' => __( 'All template parts' ), + 'slug' => 'all-parts', + ), + ); + + $areas = get_allowed_block_template_part_areas(); + + // Ensure default areas appear in a consistent order. + $preferred_order = array( 'header', 'footer', 'sidebar', 'navigation-overlay', 'uncategorized' ); + $ordered_areas = array(); + $remaining_areas = array(); + foreach ( $areas as $area ) { + $position = array_search( $area['area'], $preferred_order, true ); + if ( false !== $position ) { + $ordered_areas[ $position ] = $area; + } else { + $remaining_areas[] = $area; + } + } + ksort( $ordered_areas ); + $areas = array_merge( array_values( $ordered_areas ), $remaining_areas ); + + foreach ( $areas as $area ) { + $view_list[] = array( + 'title' => $area['label'], + 'slug' => $area['area'], + 'view' => array( + 'filters' => array( + array( + 'field' => 'area', + 'operator' => 'is', + 'value' => $area['area'], + 'isLocked' => true, + ), + ), + ), + ); + } + + $config['view_list'] = $view_list; + + return $config; +} + +/** + * Provides the view configuration for the `wp_template` post type. + * + * @since 7.1.0 + * + * @param array $config { + * The view configuration for the entity. + * } + * @return array The filtered view configuration. + */ +function _wp_get_entity_view_config_post_type_wp_template( $config ) { + $config['default_view'] = array( + 'type' => 'grid', + 'perPage' => 20, + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + 'titleField' => 'title', + 'descriptionField' => 'description', + 'mediaField' => 'preview', + 'fields' => array( 'author', 'active', 'slug', 'theme' ), + 'filters' => array(), + 'showMedia' => true, + ); + + $config['default_layouts'] = array( + 'table' => array( 'showMedia' => false ), + 'grid' => array( 'showMedia' => true ), + 'list' => array( 'showMedia' => false ), + ); + + $view_list = array( + array( + 'title' => __( 'All templates' ), + 'slug' => 'all', + ), + ); + + $templates = get_block_templates( array(), 'wp_template' ); + + // Collect unique authors, tracking whether they come from a registered + // source (theme, plugin, site) so we can sort those before user ones. + $seen_authors = array(); + $registered_authors = array(); + $user_authors = array(); + foreach ( $templates as $template ) { + /* + * Determine the original source of the template ('theme', 'plugin', + * 'site', or 'user'). + */ + $original_source = 'user'; + if ( 'wp_template' === $template->type || 'wp_template_part' === $template->type ) { + if ( $template->has_theme_file && + ( 'theme' === $template->origin || ( + empty( $template->origin ) && in_array( + $template->source, + array( + 'theme', + 'custom', + ), + true + ) ) + ) + ) { + /* + * Added by theme. + * Template originally provided by a theme, but customized by a user. + * Templates originally didn't have the 'origin' field so identify + * older customized templates by checking for no origin and a 'theme' + * or 'custom' source. + */ + $original_source = 'theme'; + } elseif ( 'plugin' === $template->origin ) { + // Added by plugin. + $original_source = 'plugin'; + } elseif ( empty( $template->has_theme_file ) && 'custom' === $template->source && empty( $template->author ) ) { + /* + * Added by site. + * Template was created from scratch, but has no author. Author support + * was only added to templates in WordPress 5.9. Fallback to showing the + * site logo and title. + */ + $original_source = 'site'; + } + } + + // Determine a human readable text for the author of the template. + $author_text = ''; + switch ( $original_source ) { + case 'theme': + $theme_name = wp_get_theme( $template->theme )->get( 'Name' ); + $author_text = empty( $theme_name ) ? $template->theme : $theme_name; + break; + case 'plugin': + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $plugin_name = ''; + if ( isset( $template->plugin ) ) { + $plugins = wp_get_active_and_valid_plugins(); + + foreach ( $plugins as $plugin_file ) { + $plugin_basename = plugin_basename( $plugin_file ); + list( $plugin_slug, ) = explode( '/', $plugin_basename ); + + if ( $plugin_slug === $template->plugin ) { + $plugin_data = get_plugin_data( $plugin_file ); + + if ( ! empty( $plugin_data['Name'] ) ) { + $plugin_name = $plugin_data['Name']; + } + + break; + } + } + } + + /* + * Fall back to the theme name if the plugin is not defined. That's needed to keep backwards + * compatibility with templates that were registered before the plugin attribute was added. + */ + if ( '' === $plugin_name ) { + $plugins = get_plugins(); + $plugin_basename = plugin_basename( sanitize_text_field( $template->theme . '.php' ) ); + if ( isset( $plugins[ $plugin_basename ] ) && isset( $plugins[ $plugin_basename ]['Name'] ) ) { + $plugin_name = $plugins[ $plugin_basename ]['Name']; + } else { + $plugin_name = $template->plugin ?? $template->theme; + } + } + $author_text = $plugin_name; + break; + case 'site': + $author_text = get_bloginfo( 'name' ); + break; + case 'user': + $author = get_user_by( 'id', $template->author ); + if ( ! $author ) { + $author_text = __( 'Unknown author' ); + } else { + $author_text = $author->get( 'display_name' ); + } + break; + } + + if ( ! empty( $author_text ) && ! isset( $seen_authors[ $author_text ] ) ) { + $seen_authors[ $author_text ] = true; + $entry = array( + 'title' => $author_text, + 'slug' => $author_text, + 'view' => array( + 'filters' => array( + array( + 'field' => 'author', + 'operator' => 'is', + 'value' => $author_text, + 'isLocked' => true, + ), + ), + ), + ); + if ( 'user' === $original_source ) { + $user_authors[] = $entry; + } else { + $registered_authors[] = $entry; + } + } + } + + $config['view_list'] = array_merge( $view_list, $registered_authors, $user_authors ); + + $config['form'] = array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'description', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'top', + ), + ), + array( + 'id' => 'description_readonly', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), + ), + array( + 'id' => 'last_edited_date', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'none', + ), + ), + 'revisions', + // The following fields are only meaningful in the `home`/`index` + // template summary. They edit other entities (`root/site` and the + // posts page); the editor merges those records into the form data + // under a namespace and controls when the fields are shown. + 'posts_page_title', + 'posts_per_page', + 'default_comment_status', + ), + ); + + return $config; +} + +/** + * Registers the default entity view configuration filters. + * + * Each post type with a bundled configuration registers a + * `_wp_get_entity_view_config_post_type_{$post_type}` callback on its + * `get_entity_view_config_postType_{$post_type}` hook. Plugins (such as + * Gutenberg) may remove these defaults and install their own. + * + * @since 7.1.0 + */ +function _wp_register_entity_view_config_filters() { + $post_types = array( 'page', 'post', 'wp_block', 'wp_template_part', 'wp_template' ); + + foreach ( $post_types as $post_type ) { + add_filter( + "get_entity_view_config_postType_{$post_type}", + "_wp_get_entity_view_config_post_type_{$post_type}", + 10, + 1 + ); + } +} +add_action( 'init', '_wp_register_entity_view_config_filters' ); diff --git a/src/wp-settings.php b/src/wp-settings.php index ef5c7784ee561..58df5ad190539 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -205,6 +205,7 @@ require ABSPATH . WPINC . '/theme-templates.php'; require ABSPATH . WPINC . '/theme-previews.php'; require ABSPATH . WPINC . '/template.php'; +require ABSPATH . WPINC . '/view-config.php'; require ABSPATH . WPINC . '/https-detection.php'; require ABSPATH . WPINC . '/https-migration.php'; require ABSPATH . WPINC . '/class-wp-user-request.php'; @@ -357,6 +358,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-view-config-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php'; diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php new file mode 100644 index 0000000000000..dd4186ab5acad --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -0,0 +1,213 @@ +user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + /** + * Deletes shared users. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * Dispatches a request to the view-config route. + * + * @param string $kind Entity kind. + * @param string $name Entity name. + * @return WP_REST_Response + */ + private function dispatch_request( $kind = 'postType', $name = 'page' ) { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + if ( null !== $kind ) { + $request->set_param( 'kind', $kind ); + } + if ( null !== $name ) { + $request->set_param( 'name', $name ); + } + return rest_get_server()->dispatch( $request ); + } + + /** + * The route is registered. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( self::ROUTE, $routes ); + } + + /** + * Editors (with `edit_posts`) can read the view config. + * + * @covers ::get_items_permissions_check + * @covers ::get_items + */ + public function test_get_items_allows_users_with_edit_posts() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request(); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Subscribers (without `edit_posts`) are forbidden. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_forbids_users_without_edit_posts() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_request(); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * Logged-out users are unauthorized. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + + $response = $this->dispatch_request(); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + /** + * Both `kind` and `name` are required. + * + * @covers ::register_routes + */ + public function test_get_items_requires_kind_and_name() { + wp_set_current_user( self::$editor_id ); + + $missing_name = $this->dispatch_request( 'postType', null ); + $this->assertErrorResponse( 'rest_missing_callback_param', $missing_name, 400 ); + + $missing_kind = $this->dispatch_request( null, 'page' ); + $this->assertErrorResponse( 'rest_missing_callback_param', $missing_kind, 400 ); + } + + /** + * The response echoes the requested entity and the documented config keys. + * + * @covers ::get_items + */ + public function test_get_items_returns_entity_and_config_shape() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request( 'postType', 'page' ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'postType', $data['kind'] ); + $this->assertSame( 'page', $data['name'] ); + $this->assertArrayHasKey( 'default_view', $data ); + $this->assertArrayHasKey( 'default_layouts', $data ); + $this->assertArrayHasKey( 'view_list', $data ); + $this->assertArrayHasKey( 'form', $data ); + } + + /** + * The response body matches wp_get_entity_view_config() for the entity. + * + * @covers ::get_items + */ + public function test_get_items_matches_underlying_config() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request( 'postType', 'page' ); + // Normalize through JSON to compare what a client actually receives: + // the response's object casts collapse back to the source arrays. + $data = json_decode( wp_json_encode( $response->get_data() ), true ); + $config = wp_get_entity_view_config( 'postType', 'page' ); + + $this->assertSame( $config['default_view'], $data['default_view'] ); + $this->assertSame( $config['default_layouts'], $data['default_layouts'] ); + $this->assertSame( $config['view_list'], $data['view_list'] ); + $this->assertSame( $config['form'], $data['form'] ); + } + + /** + * Empty object-typed config values serialize as JSON objects ({}), not arrays ([]). + * + * @covers ::get_items + */ + public function test_empty_object_fields_serialize_as_json_objects() { + wp_set_current_user( self::$editor_id ); + + // An entity with no provider yields empty default_layouts entries and form. + $decoded = json_decode( wp_json_encode( $this->dispatch_request( 'custom_kind', 'custom_name' )->get_data() ) ); + + $this->assertIsObject( $decoded->form ); + $this->assertIsObject( $decoded->default_layouts->table ); + $this->assertIsObject( $decoded->default_layouts->grid ); + $this->assertIsObject( $decoded->default_layouts->list ); + + // wp_template_part yields an empty default_view.layout and grid layout. + $decoded = json_decode( wp_json_encode( $this->dispatch_request( 'postType', 'wp_template_part' )->get_data() ) ); + + $this->assertIsObject( $decoded->default_view->layout ); + $this->assertIsObject( $decoded->default_layouts->grid->layout ); + } + + /** + * The item schema exposes the documented top-level properties. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema() { + $controller = new WP_REST_View_Config_Controller(); + $schema = $controller->get_item_schema(); + + $this->assertSame( 'view-config', $schema['title'] ); + $this->assertSameSets( + array( 'kind', 'name', 'default_view', 'default_layouts', 'view_list', 'form' ), + array_keys( $schema['properties'] ) + ); + } +} diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php new file mode 100644 index 0000000000000..96faac357e763 --- /dev/null +++ b/tests/phpunit/tests/view-config.php @@ -0,0 +1,226 @@ + 'table', + 'filters' => array(), + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + 'perPage' => 20, + 'fields' => array( 'author', 'status' ), + 'titleField' => 'title', + ); + + /** + * The default layouts shared by all entities. + * + * @var array + */ + const DEFAULT_LAYOUTS = array( + 'table' => array(), + 'grid' => array(), + 'list' => array(), + ); + + /** + * The default view list for an entity with no specific provider. + * + * @var array + */ + const DEFAULT_VIEW_LIST = array( + array( + 'title' => 'All items', + 'slug' => 'all', + ), + ); + + /** + * The default form configuration shared by all entities. + * + * @var array + */ + const DEFAULT_FORM = array(); + + /** + * Tears down each test. + */ + public function tear_down() { + remove_all_filters( 'get_entity_view_config_postType_unregistered_cpt' ); + remove_all_filters( 'get_entity_view_config_custom_kind_custom_name' ); + parent::tear_down(); + } + + /** + * The default configuration exposes the documented shape for an unknown entity. + */ + public function test_returns_default_config_shape_for_unknown_entity() { + $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertIsArray( $config ); + $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); + $this->assertSame( self::DEFAULT_VIEW, $config['default_view'] ); + $this->assertSame( self::DEFAULT_LAYOUTS, $config['default_layouts'] ); + $this->assertSame( self::DEFAULT_VIEW_LIST, $config['view_list'] ); + $this->assertSame( self::DEFAULT_FORM, $config['form'] ); + } + + /** + * The base "all items" view falls back to a generic title for an unknown post type. + */ + public function test_view_list_falls_back_to_generic_all_items_title() { + $config = wp_get_entity_view_config( 'postType', 'does_not_exist' ); + + $this->assertCount( 1, $config['view_list'] ); + $this->assertSame( 'all', $config['view_list'][0]['slug'] ); + $this->assertSame( 'All items', $config['view_list'][0]['title'] ); + } + + /** + * For a registered post type, the "all items" view uses the post type's label. + */ + public function test_view_list_uses_post_type_all_items_label() { + register_post_type( + 'view_config_cpt', + array( + 'labels' => array( + 'all_items' => 'All Custom Things', + ), + ) + ); + + $config = wp_get_entity_view_config( 'postType', 'view_config_cpt' ); + + $this->assertSame( 'All Custom Things', $config['view_list'][0]['title'] ); + + unregister_post_type( 'view_config_cpt' ); + } + + /** + * The dynamic filter receives the default config and the entity descriptor. + */ + public function test_filter_receives_config_and_entity() { + $received_entity = null; + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function ( $config, $entity ) use ( &$received_entity ) { + $received_entity = $entity; + return $config; + }, + 10, + 2 + ); + + wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertSame( + array( + 'kind' => 'custom_kind', + 'name' => 'custom_name', + ), + $received_entity + ); + } + + /** + * A filter can override the configuration values. + */ + public function test_filter_can_override_config() { + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function ( $config ) { + $config['default_view']['type'] = 'grid'; + return $config; + } + ); + + $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertSame( 'grid', $config['default_view']['type'] ); + } + + /** + * Dropped keys are backfilled with their defaults. + */ + public function test_filter_dropped_keys_are_backfilled() { + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function () { + // Return a config that is missing most of the documented keys. + return array( 'form' => array( 'custom' => true ) ); + } + ); + + $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); + $this->assertSame( array( 'custom' => true ), $config['form'] ); + // Backfilled from defaults. + $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); + $this->assertSame( self::DEFAULT_VIEW, $config['default_view'] ); + $this->assertSame( self::DEFAULT_LAYOUTS, $config['default_layouts'] ); + $this->assertSame( self::DEFAULT_VIEW_LIST, $config['view_list'] ); + } + + /** + * Keys introduced by a filter that are not part of the documented shape are discarded. + */ + public function test_filter_unknown_keys_are_discarded() { + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function ( $config ) { + $config['not_a_real_key'] = 'nope'; + return $config; + } + ); + + $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertArrayNotHasKey( 'not_a_real_key', $config ); + $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); + } + + /** + * A non-array filter return falls back to the default config. + */ + public function test_non_array_filter_return_falls_back_to_default() { + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function () { + return 'not an array'; + } + ); + + $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertIsArray( $config ); + $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); + $this->assertSame( self::DEFAULT_VIEW, $config['default_view'] ); + $this->assertSame( self::DEFAULT_LAYOUTS, $config['default_layouts'] ); + $this->assertSame( self::DEFAULT_VIEW_LIST, $config['view_list'] ); + $this->assertSame( self::DEFAULT_FORM, $config['form'] ); + } +} From cffc057b731c1db4c901f3593e17254fd8a9603f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:55:48 +0200 Subject: [PATCH 02/12] Tests: Add the view-config route to the REST schema expectations. The View Config controller registers `/wp/v2/view-config`, so include it in the expected routes for WP_Test_REST_Schema_Initialization::test_expected_routes_in_schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/phpunit/tests/rest-api/rest-schema-setup.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..2b99a10fecca2 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -202,6 +202,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)', '/wp/v2/icons', '/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', + '/wp/v2/view-config', '/wp-abilities/v1', '/wp-abilities/v1/categories', '/wp-abilities/v1/categories/(?P[a-z0-9]+(?:-[a-z0-9]+)*)', From 6274ff550c7860a4127851c4c5bd4f75c162fa00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:59:25 +0200 Subject: [PATCH 03/12] Remove unnecessary check in tests --- tests/phpunit/tests/view-config.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php index 96faac357e763..8aa4ace4eb592 100644 --- a/tests/phpunit/tests/view-config.php +++ b/tests/phpunit/tests/view-config.php @@ -179,7 +179,6 @@ function () { $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); $this->assertSame( array( 'custom' => true ), $config['form'] ); // Backfilled from defaults. - $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); $this->assertSame( self::DEFAULT_VIEW, $config['default_view'] ); $this->assertSame( self::DEFAULT_LAYOUTS, $config['default_layouts'] ); $this->assertSame( self::DEFAULT_VIEW_LIST, $config['view_list'] ); From 98c33c557fa2d65cb57b64f746734850b5d91f2b Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Mon, 22 Jun 2026 17:26:42 +0300 Subject: [PATCH 04/12] Tests: Regenerate the REST API client fixtures for the view-config route. --- tests/qunit/fixtures/wp-api-generated.js | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..16067d33edbe4 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12771,6 +12771,38 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp/v2/view-config": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "kind": { + "description": "Entity kind.", + "type": "string", + "required": true + }, + "name": { + "description": "Entity name.", + "type": "string", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/view-config" + } + ] + } } }, "image_sizes": { From 3f6f100d1c3f9671fc0173f1908ea5f49c213474 Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Tue, 23 Jun 2026 09:32:17 +0300 Subject: [PATCH 05/12] backport 79399 --- src/wp-includes/view-config.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index 74d730096e42e..07e03f67467b5 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -555,6 +555,20 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $config ) { $config['view_list'] = $view_list; + $config['form'] = array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'last_edited_date', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'none', + ), + ), + 'revisions', + ), + ); + return $config; } From d70a9b168353e7ab8af015f5001f5c10d91846ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:26:02 +0200 Subject: [PATCH 06/12] Move filter callbacks to default-filters --- src/wp-includes/default-filters.php | 11 +++++++++++ src/wp-includes/view-config.php | 24 ------------------------ 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 2479b6f173110..e09a042de9cd1 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -818,4 +818,15 @@ add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' ); add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' ); +// View Config API. +$post_types = array( 'page', 'post', 'wp_block', 'wp_template_part', 'wp_template' ); +foreach ( $post_types as $post_type ) { + add_filter( + "get_entity_view_config_postType_{$post_type}", + "_wp_get_entity_view_config_post_type_{$post_type}", + 10, + 1 + ); +} + unset( $filter, $action ); diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index 07e03f67467b5..810fafb840384 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -781,27 +781,3 @@ function _wp_get_entity_view_config_post_type_wp_template( $config ) { return $config; } - -/** - * Registers the default entity view configuration filters. - * - * Each post type with a bundled configuration registers a - * `_wp_get_entity_view_config_post_type_{$post_type}` callback on its - * `get_entity_view_config_postType_{$post_type}` hook. Plugins (such as - * Gutenberg) may remove these defaults and install their own. - * - * @since 7.1.0 - */ -function _wp_register_entity_view_config_filters() { - $post_types = array( 'page', 'post', 'wp_block', 'wp_template_part', 'wp_template' ); - - foreach ( $post_types as $post_type ) { - add_filter( - "get_entity_view_config_postType_{$post_type}", - "_wp_get_entity_view_config_post_type_{$post_type}", - 10, - 1 - ); - } -} -add_action( 'init', '_wp_register_entity_view_config_filters' ); From 71591458710c8f4ec18c531d95d57033f290fd25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:09:37 +0200 Subject: [PATCH 07/12] Improve capability checks --- .../class-wp-rest-view-config-controller.php | 59 +++++++- .../rest-api/rest-view-config-controller.php | 134 +++++++++++++++++- 2 files changed, 190 insertions(+), 3 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 4c544c73d903f..97948af466af7 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 @@ -68,7 +68,20 @@ public function register_routes() { * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function get_items_permissions_check( $request ) { - if ( ! current_user_can( 'edit_posts' ) ) { + $kind = $request->get_param( 'kind' ); + $name = $request->get_param( 'name' ); + + $capability = $this->get_required_capability( $kind, $name ); + + if ( null === $capability ) { + return new WP_Error( + 'rest_view_config_invalid_entity', + __( 'Invalid entity kind or name.' ), + array( 'status' => 404 ) + ); + } + + if ( ! current_user_can( $capability ) ) { return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to read view config.' ), @@ -79,6 +92,50 @@ public function get_items_permissions_check( $request ) { return true; } + /** + * Resolves the capability required to read the view config for an entity. + * + * Known kinds map to the capability that gates managing that entity's list: + * post types use their own `edit_posts` capability (which honors custom + * `capability_type` registrations), taxonomies use `manage_terms`, and + * root-level entities use `manage_options`. A post type or taxonomy that is + * not registered, or not exposed to the REST API, resolves to `null` so the + * request is treated as referencing an unknown entity. + * + * Any other kind falls back to `edit_posts`. This keeps entities registered + * through the `get_entity_view_config_{$kind}_{$name}` filter readable behind + * a baseline capability. + * + * @since 7.1.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return string|null Capability required to read the config, or null if the + * entity is not registered. + */ + private function get_required_capability( $kind, $name ) { + switch ( $kind ) { + case 'postType': + $post_type = get_post_type_object( $name ); + if ( $post_type && $post_type->show_in_rest ) { + return $post_type->cap->edit_posts; + } + return null; + + case 'taxonomy': + $taxonomy = get_taxonomy( $name ); + if ( $taxonomy && $taxonomy->show_in_rest ) { + return $taxonomy->cap->manage_terms; + } + return null; + + case 'root': + return 'manage_options'; + } + + return 'edit_posts'; + } + /** * Returns the default view configuration for the given entity type. * 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 dd4186ab5acad..79ed02996fdfd 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -18,7 +18,14 @@ class WP_REST_View_Config_Controller_Test extends WP_Test_REST_TestCase { const ROUTE = '/wp/v2/view-config'; /** - * Editor user id (has `edit_posts`). + * Administrator user id (has `edit_theme_options` and `manage_options`). + * + * @var int + */ + protected static $admin_id; + + /** + * Editor user id (has `edit_posts` and `manage_categories`, lacks `manage_options`). * * @var int */ @@ -37,6 +44,7 @@ class WP_REST_View_Config_Controller_Test extends WP_Test_REST_TestCase { * @param WP_UnitTest_Factory $factory Factory instance. */ 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' ) ); } @@ -45,6 +53,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { * Deletes shared users. */ public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); self::delete_user( self::$editor_id ); self::delete_user( self::$subscriber_id ); } @@ -117,6 +126,126 @@ public function test_get_items_requires_authentication() { $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); } + /** + * Post type config is gated by that post type's own `edit_posts` capability, + * honoring custom capability registrations. + * + * `wp_template_part` maps `edit_posts` to `edit_theme_options`, which editors + * lack but administrators have. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_uses_post_type_specific_capability() { + wp_set_current_user( self::$editor_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'postType', 'wp_template_part' ), + 403 + ); + + wp_set_current_user( self::$admin_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'postType', 'wp_template_part' )->get_status() + ); + } + + /** + * An unregistered post type is treated as an unknown entity (404). + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_unknown_post_type_is_not_found() { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_request( 'postType', 'does_not_exist' ); + + $this->assertErrorResponse( 'rest_view_config_invalid_entity', $response, 404 ); + } + + /** + * Taxonomy config is gated by the taxonomy's `manage_terms` capability. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_uses_taxonomy_capability() { + // Editors have `manage_categories` (the `manage_terms` cap for `category`). + wp_set_current_user( self::$editor_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'taxonomy', 'category' )->get_status() + ); + + // Subscribers do not. + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'taxonomy', 'category' ), + 403 + ); + } + + /** + * An unregistered taxonomy is treated as an unknown entity (404). + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_unknown_taxonomy_is_not_found() { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_request( 'taxonomy', 'does_not_exist' ); + + $this->assertErrorResponse( 'rest_view_config_invalid_entity', $response, 404 ); + } + + /** + * Root-level config requires `manage_options`. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_root_requires_manage_options() { + // Editors lack `manage_options`. + wp_set_current_user( self::$editor_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'root', 'site' ), + 403 + ); + + wp_set_current_user( self::$admin_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'root', 'site' )->get_status() + ); + } + + /** + * Unknown kinds fall back to the baseline `edit_posts` capability so that + * entities registered through the view config filter remain readable. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_unknown_kind_falls_back_to_edit_posts() { + wp_set_current_user( self::$editor_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'custom_kind', 'custom_name' )->get_status() + ); + + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'custom_kind', 'custom_name' ), + 403 + ); + } + /** * Both `kind` and `name` are required. * @@ -178,7 +307,8 @@ public function test_get_items_matches_underlying_config() { * @covers ::get_items */ public function test_empty_object_fields_serialize_as_json_objects() { - wp_set_current_user( self::$editor_id ); + // Admin: reading wp_template_part config requires `edit_theme_options`. + wp_set_current_user( self::$admin_id ); // An entity with no provider yields empty default_layouts entries and form. $decoded = json_decode( wp_json_encode( $this->dispatch_request( 'custom_kind', 'custom_name' )->get_data() ) ); From f983214c8a351920209cd0609e9c0ffdcf927754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:45:53 +0200 Subject: [PATCH 08/12] Cast empty objects according to schema --- .../class-wp-rest-view-config-controller.php | 105 ++++++++++++++---- .../rest-api/rest-view-config-controller.php | 38 +++++++ 2 files changed, 123 insertions(+), 20 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 97948af466af7..15087dc1b0332 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 @@ -149,37 +149,102 @@ public function get_items( $request ) { $name = $request->get_param( 'name' ); $config = wp_get_entity_view_config( $kind, $name ); + $schema = $this->get_item_schema(); /* - * The schema types these as objects, but PHP encodes empty arrays as - * JSON arrays ([]). Cast the object-typed values that may be empty so - * they serialize as JSON objects ({}) and match the schema. + * PHP encodes empty arrays as JSON arrays ([]), but the schema types + * several of these values as objects. Walk the config against the schema + * and cast empty arrays to objects so they serialize as JSON objects ({}) + * and match the schema, regardless of which entity (or filter) produced + * the data. */ - $default_view = $config['default_view']; - if ( isset( $default_view['layout'] ) ) { - $default_view['layout'] = (object) $default_view['layout']; - } - - $default_layouts = $config['default_layouts']; - foreach ( $default_layouts as $view_type => $layout ) { - if ( isset( $layout['layout'] ) ) { - $layout['layout'] = (object) $layout['layout']; - } - $default_layouts[ $view_type ] = (object) $layout; - } - $response = array( 'kind' => $kind, 'name' => $name, - 'default_view' => $default_view, - 'default_layouts' => $default_layouts, - 'view_list' => $config['view_list'], - 'form' => (object) $config['form'], + 'default_view' => $this->cast_empty_objects( $config['default_view'], $schema['properties']['default_view'] ), + 'default_layouts' => $this->cast_empty_objects( $config['default_layouts'], $schema['properties']['default_layouts'] ), + 'view_list' => $this->cast_empty_objects( $config['view_list'], $schema['properties']['view_list'] ), + 'form' => $this->cast_empty_objects( $config['form'], $schema['properties']['form'] ), ); return rest_ensure_response( $response ); } + /** + * Recursively casts empty arrays to objects where the schema types them as + * objects. + * + * PHP cannot distinguish an empty associative array from an empty list, so + * `json_encode()` always serializes `array()` as a JSON array (`[]`). The + * REST schema, however, types several values as objects, which must encode + * as `{}`. This walks the value against its schema and casts any empty, + * object-typed array to an object. Non-empty associative arrays already + * encode as objects, so they are left as arrays and only recursed into to + * fix any nested empty objects. + * + * Union schemas (`oneOf`/`anyOf`) are handled only for the empty-array case: + * an empty value is cast to an object when any branch allows an object. Such + * values are not recursed into, which is sufficient for the form schema + * where they never contain empty nested objects. + * + * @since 7.1.0 + * + * @param mixed $value The value to normalize. + * @param array $schema The schema node describing the value. + * @return mixed The normalized value, with empty object-typed arrays cast to objects. + */ + private function cast_empty_objects( $value, $schema ) { + if ( ! is_array( $value ) || ! is_array( $schema ) ) { + return $value; + } + + if ( isset( $schema['oneOf'] ) || isset( $schema['anyOf'] ) ) { + $branches = isset( $schema['oneOf'] ) ? $schema['oneOf'] : $schema['anyOf']; + if ( array() === $value ) { + foreach ( $branches as $branch ) { + if ( is_array( $branch ) && in_array( 'object', (array) ( isset( $branch['type'] ) ? $branch['type'] : array() ), true ) ) { + return (object) array(); + } + } + } + return $value; + } + + $types = (array) ( isset( $schema['type'] ) ? $schema['type'] : array() ); + + if ( in_array( 'array', $types, true ) && isset( $schema['items'] ) ) { + foreach ( $value as $index => $item ) { + $value[ $index ] = $this->cast_empty_objects( $item, $schema['items'] ); + } + return $value; + } + + if ( in_array( 'object', $types, true ) ) { + if ( isset( $schema['properties'] ) ) { + foreach ( $schema['properties'] as $property => $property_schema ) { + if ( array_key_exists( $property, $value ) ) { + $value[ $property ] = $this->cast_empty_objects( $value[ $property ], $property_schema ); + } + } + } + if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { + foreach ( $value as $key => $item ) { + if ( isset( $schema['properties'][ $key ] ) ) { + continue; + } + $value[ $key ] = $this->cast_empty_objects( $item, $schema['additionalProperties'] ); + } + } + + // Empty object-typed arrays must serialize as {} to match the schema. + if ( array() === $value ) { + return (object) array(); + } + } + + return $value; + } + /** * Retrieves the item's schema, conforming to JSON Schema. * 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 79ed02996fdfd..b487055ff14d8 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -325,6 +325,44 @@ public function test_empty_object_fields_serialize_as_json_objects() { $this->assertIsObject( $decoded->default_layouts->grid->layout ); } + /** + * Empty object-typed values nested inside a `view_list` item's `view` + * override serialize as JSON objects ({}), not arrays ([]). + * + * The `view.layout` (and its `styles` map) are typed as objects by the + * schema but are not produced by core data, so this exercises the + * schema-driven cast against a value supplied through the documented + * `get_entity_view_config_{$kind}_{$name}` filter. + * + * @covers ::get_items + */ + public function test_empty_objects_inside_view_list_view_serialize_as_json_objects() { + wp_set_current_user( self::$editor_id ); + + $filter = static function ( $config ) { + $config['view_list'][] = array( + 'title' => 'Custom', + 'slug' => 'custom', + 'view' => array( + 'type' => 'table', + 'layout' => array( + 'styles' => array(), + ), + ), + ); + return $config; + }; + add_filter( 'get_entity_view_config_custom_kind_custom_name', $filter ); + + $decoded = json_decode( wp_json_encode( $this->dispatch_request( 'custom_kind', 'custom_name' )->get_data() ) ); + + remove_filter( 'get_entity_view_config_custom_kind_custom_name', $filter ); + + $view = end( $decoded->view_list ); + $this->assertIsObject( $view->view->layout ); + $this->assertIsObject( $view->view->layout->styles ); + } + /** * The item schema exposes the documented top-level properties. * From 5cbe2f8c5bc2d4942d337597191874f6c7670abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:26:03 +0200 Subject: [PATCH 09/12] Make all private functions protected so they can be overridden in gutenberg, if necessary --- .../class-wp-rest-view-config-controller.php | 22 +++++++++---------- 1 file changed, 11 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 15087dc1b0332..355c596912d11 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 @@ -113,7 +113,7 @@ public function get_items_permissions_check( $request ) { * @return string|null Capability required to read the config, or null if the * entity is not registered. */ - private function get_required_capability( $kind, $name ) { + protected function get_required_capability( $kind, $name ) { switch ( $kind ) { case 'postType': $post_type = get_post_type_object( $name ); @@ -193,7 +193,7 @@ public function get_items( $request ) { * @param array $schema The schema node describing the value. * @return mixed The normalized value, with empty object-typed arrays cast to objects. */ - private function cast_empty_objects( $value, $schema ) { + protected function cast_empty_objects( $value, $schema ) { if ( ! is_array( $value ) || ! is_array( $schema ) ) { return $value; } @@ -396,7 +396,7 @@ public function get_item_schema() { * * @return array Schema properties for the base view configuration. */ - private function get_view_base_schema() { + protected function get_view_base_schema() { return array( 'search' => array( 'type' => 'string', @@ -507,7 +507,7 @@ private function get_view_base_schema() { * * @return array Schema for a column style object. */ - private function get_column_style_schema() { + protected function get_column_style_schema() { return array( 'type' => 'object', 'properties' => array( @@ -535,7 +535,7 @@ private function get_column_style_schema() { * * @return array Schema for a table layout object. */ - private function get_table_layout_schema() { + protected function get_table_layout_schema() { return array( 'type' => 'object', 'properties' => array( @@ -561,7 +561,7 @@ private function get_table_layout_schema() { * * @return array Schema for a list layout object. */ - private function get_list_layout_schema() { + protected function get_list_layout_schema() { return array( 'type' => 'object', 'properties' => array( @@ -584,7 +584,7 @@ private function get_list_layout_schema() { * * @return array Schema for a combined layout object. */ - private function get_combined_layout_schema() { + protected function get_combined_layout_schema() { return array( 'type' => 'object', 'properties' => array_merge( @@ -602,7 +602,7 @@ private function get_combined_layout_schema() { * * @return array Schema for a grid layout object. */ - private function get_grid_layout_schema() { + protected function get_grid_layout_schema() { return array( 'type' => 'object', 'properties' => array( @@ -633,7 +633,7 @@ private function get_grid_layout_schema() { * * @return array Schema for a form layout object. */ - private function get_form_layout_schema() { + protected function get_form_layout_schema() { return array( 'oneOf' => array( // RegularLayout. @@ -795,7 +795,7 @@ private function get_form_layout_schema() { * * @return array Schema for a form field. */ - private function get_form_field_schema() { + protected function get_form_field_schema() { return array( 'oneOf' => array( array( 'type' => 'string' ), @@ -838,7 +838,7 @@ private function get_form_field_schema() { * * @return array Schema properties for the form configuration. */ - private function get_form_schema() { + protected function get_form_schema() { return array( 'layout' => $this->get_form_layout_schema(), 'fields' => array( From 8c90c83f70d9cbbcf6b31fb78b001ffa09bd4061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:27:19 +0200 Subject: [PATCH 10/12] Address feedback --- src/wp-includes/default-filters.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index e09a042de9cd1..c3ecd5b79249d 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -819,8 +819,7 @@ add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' ); // View Config API. -$post_types = array( 'page', 'post', 'wp_block', 'wp_template_part', 'wp_template' ); -foreach ( $post_types as $post_type ) { +foreach ( array( 'page', 'post', 'wp_block', 'wp_template_part', 'wp_template' ) as $post_type ) { add_filter( "get_entity_view_config_postType_{$post_type}", "_wp_get_entity_view_config_post_type_{$post_type}", From 21e0013d9c5837f4889af34d3d08da2ac5386135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:27:57 +0200 Subject: [PATCH 11/12] Address feedback --- .../endpoints/class-wp-rest-view-config-controller.php | 7 ------- 1 file changed, 7 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 355c596912d11..e8aac4ccfafff 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 @@ -151,13 +151,6 @@ public function get_items( $request ) { $config = wp_get_entity_view_config( $kind, $name ); $schema = $this->get_item_schema(); - /* - * PHP encodes empty arrays as JSON arrays ([]), but the schema types - * several of these values as objects. Walk the config against the schema - * and cast empty arrays to objects so they serialize as JSON objects ({}) - * and match the schema, regardless of which entity (or filter) produced - * the data. - */ $response = array( 'kind' => $kind, 'name' => $name, From c1f8ba6f8a40b1d7729f9ff5cbdcb1388cd4fc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:36:25 +0200 Subject: [PATCH 12/12] Unset $post_type variable used in foreach --- src/wp-includes/default-filters.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index c3ecd5b79249d..3d00e5ae1ba22 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -828,4 +828,4 @@ ); } -unset( $filter, $action ); +unset( $filter, $action, $post_type );