From f3ceadfd65d780ed98ad1ae91d6e921e16f93637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:36:28 +0200 Subject: [PATCH 1/5] Entity Fields: Introduce the Entity Fields API. Adds a registry of the fields of an entity, identified by its kind and name, along with `wp_register_entity_fields()`, `wp_update_entity_fields()`, `wp_unregister_entity_fields()`, and `wp_register_entity_field_collection()`, which only run on the new `wp_entity_fields_api_init` action, and the functions that read the registry. The registry is filled lazily: the action fires the first time the fields are read, once `init` has completed. A field definition is the serializable subset of the DataViews Field API. Its JavaScript parts ship in a script module registered along with the fields. The script modules of the fields are added as dynamic `module_dependencies` of the `wp-editor` script on `admin_footer`, so they reach the import map of the pages that load the editor. The `WP_REST_Entity_Fields_Controller` class is introduced too, as the registry validates the field types against its schema. Its route is registered separately. Synced from https://github.com/WordPress/gutenberg/pull/84078. Synced from https://github.com/WordPress/gutenberg/pull/84168. Synced from https://github.com/WordPress/gutenberg/pull/84691. See #66229. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-entity-fields-registry.php | 913 ++++++++++++++++++ src/wp-includes/default-filters.php | 3 + src/wp-includes/entity-fields.php | 254 +++++ ...class-wp-rest-entity-fields-controller.php | 398 ++++++++ src/wp-settings.php | 3 + .../duplicate/book/authorship/field.php | 11 + .../collections/duplicate/book/index.php | 12 + .../collections/invalid/book/index.php | 12 + .../invalid/book/subtitle/field.php | 11 + .../collections/invalid/no_name/index.php | 11 + .../invalid/no_name/skipped/field.php | 11 + .../collections/invalid/no_origin/index.php | 11 + .../invalid/no_origin/skipped/field.php | 11 + .../collections/invalid/null_name/index.php | 12 + .../invalid/null_name/skipped/field.php | 11 + .../collections/valid/book/index.php | 13 + .../collections/valid/book/subtitle/field.php | 11 + .../collections/valid/hidden/index.php | 12 + .../collections/valid/hidden/secret/field.php | 11 + .../collections/valid/magazine/index.php | 12 + .../valid/magazine/issue/field.php | 11 + .../collections/valid/scoped/index.php | 15 + .../collections/valid/scoped/plain/field.php | 11 + .../collections/valid/scoped/scoped/field.php | 23 + .../entity-fields/fixture/alpha/field.php | 12 + .../data/entity-fields/fixture/beta/field.php | 11 + tests/phpunit/tests/entity-fields/base.php | 198 ++++ .../wpAddEntityFieldModulesToEditorScript.php | 282 ++++++ .../entity-fields/wpEntityFieldsRegistry.php | 672 +++++++++++++ .../wpRegisterEntityFieldCollection.php | 211 ++++ 30 files changed, 3189 insertions(+) create mode 100644 src/wp-includes/class-wp-entity-fields-registry.php create mode 100644 src/wp-includes/entity-fields.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php create mode 100644 tests/phpunit/data/entity-fields/collections/duplicate/book/authorship/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/duplicate/book/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/book/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/book/subtitle/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/no_name/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/no_name/skipped/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/no_origin/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/no_origin/skipped/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/null_name/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid/null_name/skipped/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/book/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/book/subtitle/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/hidden/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/hidden/secret/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/magazine/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/magazine/issue/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/scoped/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/scoped/plain/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/valid/scoped/scoped/field.php create mode 100644 tests/phpunit/data/entity-fields/fixture/alpha/field.php create mode 100644 tests/phpunit/data/entity-fields/fixture/beta/field.php create mode 100644 tests/phpunit/tests/entity-fields/base.php create mode 100644 tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php create mode 100644 tests/phpunit/tests/entity-fields/wpEntityFieldsRegistry.php create mode 100644 tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php diff --git a/src/wp-includes/class-wp-entity-fields-registry.php b/src/wp-includes/class-wp-entity-fields-registry.php new file mode 100644 index 0000000000000..3c0227931148d --- /dev/null +++ b/src/wp-includes/class-wp-entity-fields-registry.php @@ -0,0 +1,913 @@ + name => array of field id => definition. + * + * Kept nested rather than under a joined `{$kind}/{$name}` key: both parts + * may contain any character, so no separator keeps two entities apart. + * + * @since 7.2.0 + * @var array>> + */ + protected $fields = array(); + + /** + * Registered script modules, as kind => name => array of module id => + * list of the ids of the fields the module applies to, in registration + * order. + * + * @since 7.2.0 + * @var array>> + */ + protected $field_modules = array(); + + /** + * Whether the `wp_entity_fields_api_init` action has fired since the registry + * was created. + * + * @since 7.2.0 + * @var bool + */ + protected $initialized = false; + + /** + * The singleton instance. + * + * @since 7.2.0 + * @var WP_Entity_Fields_Registry|null + */ + protected static $instance = null; + + /** + * Returns the singleton instance. + * + * @since 7.2.0 + * + * @return WP_Entity_Fields_Registry The registry. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Registers fields for an entity. + * + * The fields must be new: a field with the id of a registered field is + * reported and skipped, and so is every definition after the first of an + * id that appears more than once in the call. The rest of the fields of + * the call are registered: a field another plugin got to first does not + * cost a plugin the others. A definition that is not an array with a + * non-empty string `id` is reported and skipped the same way. To change + * a registered field, see update(); to replace it, unregister it first. + * The script module, if any, applies to every field the call registers. + * A field whose `type` DataViews does not provide is reported and + * registered anyway. + * + * The origin is stored as the `origin` property of each field, as + * `registeredBy`, replacing any `origin` the definition sets. + * + * @since 7.2.0 + * + * @param string $origin Who registers the fields: `core`, or + * the slug of the plugin or theme. + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @param array[] $fields The list of field definitions, each with an `id`. + * @param string|null $script_module The id of the script module providing + * the JavaScript parts of the fields, if any. + * @return string[] The ids of the fields registered, in the order of the + * call. Empty when none is, when called outside the + * `wp_entity_fields_api_init` action, or when an argument is + * invalid. + */ + public function register( $origin, $kind, $name, $fields, $script_module = null ) { + if ( ! $this->validate_arguments( __METHOD__, $origin, $kind, $name, $fields, $script_module ) ) { + return array(); + } + $fields = $this->skip_invalid_definitions( __METHOD__, $kind, $name, $fields ); + if ( ! $fields ) { + return array(); + } + + $new_fields = array(); + $duplicated = array(); + $registered_ids = array(); + foreach ( $fields as $field ) { + $id = $field['id']; + if ( isset( $this->fields[ $kind ][ $name ][ $id ] ) ) { + $registered_ids[ $id ] = $id; + } elseif ( isset( $new_fields[ $id ] ) ) { + $duplicated[ $id ] = $id; + } else { + $new_fields[ $id ] = $field; + } + } + + if ( $duplicated ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. */ + __( 'A field can only be registered once. These fields of %1$s "%2$s" appear more than once in the same call: %3$s. Only their first definition is registered.' ), + $kind, + $name, + implode( ', ', $duplicated ) + ), + '7.2.0' + ); + } + + if ( $registered_ids ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. */ + __( 'A field can only be registered once. These fields of %1$s "%2$s" are skipped because they are already registered: %3$s. Use update() to change a registered field, or unregister it first to replace it.' ), + $kind, + $name, + implode( ', ', $registered_ids ) + ), + '7.2.0' + ); + } + + if ( ! $new_fields ) { + return array(); + } + $this->report_unknown_types( __METHOD__, $kind, $name, $new_fields ); + + foreach ( $new_fields as $field ) { + $this->fields[ $kind ][ $name ][ $field['id'] ] = array_merge( + $field, + array( + 'origin' => array( + 'registeredBy' => $origin, + 'updatedBy' => array(), + ), + ) + ); + } + $ids = array_column( $new_fields, 'id' ); + $this->add_field_module( $kind, $name, $ids, $script_module ); + + return $ids; + } + + /** + * Updates registered fields of an entity. + * + * Each definition is merged into the registered field with its id, + * property by property; the field keeps its position. The fields must be + * registered: a field that is not is reported and skipped, and so is a + * definition that is not an array with a non-empty string `id`; the rest + * of the fields of the call are updated. The script module, if any, + * applies to every field the call updates, on top of the modules the + * fields have. A field whose `type` DataViews does not provide is + * reported and updated anyway. + * + * The origin is appended to the `updatedBy` list of the `origin` property + * of each field, once; the rest of the `origin` property cannot be + * updated, and any `origin` the definition sets is ignored. + * + * Like register(), it only runs on the `wp_entity_fields_api_init` action. + * + * @since 7.2.0 + * + * @param string $origin Who updates the fields: `core`, or + * the slug of the plugin or theme. + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @param array[] $fields The list of partial field definitions, + * each with the `id` of a registered field. + * @param string|null $script_module The id of the script module providing + * the JavaScript parts of the fields, if any. + * @return string[] The ids of the fields updated, in the order of the + * call. Empty when none is, when called outside the + * `wp_entity_fields_api_init` action, or when an argument is + * invalid. + */ + public function update( $origin, $kind, $name, $fields, $script_module = null ) { + if ( ! $this->validate_arguments( __METHOD__, $origin, $kind, $name, $fields, $script_module ) ) { + return array(); + } + $fields = $this->skip_invalid_definitions( __METHOD__, $kind, $name, $fields ); + if ( ! $fields ) { + return array(); + } + $this->report_unknown_types( __METHOD__, $kind, $name, $fields ); + + $ids = array(); + $unregistered_ids = array(); + foreach ( $fields as $field ) { + $id = $field['id']; + if ( ! isset( $this->fields[ $kind ][ $name ][ $id ] ) ) { + $unregistered_ids[ $id ] = $id; + continue; + } + + $registered = $this->fields[ $kind ][ $name ][ $id ]; + $field_origin = $registered['origin']; + if ( ! in_array( $origin, $field_origin['updatedBy'], true ) ) { + $field_origin['updatedBy'][] = $origin; + } + + $this->fields[ $kind ][ $name ][ $id ] = array_merge( $registered, $field, array( 'origin' => $field_origin ) ); + if ( ! in_array( $id, $ids, true ) ) { + $ids[] = $id; + } + } + + if ( $unregistered_ids ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. */ + __( 'Only registered fields can be updated. These fields of %1$s "%2$s" are skipped because they are not registered: %3$s. Use register() to add a field.' ), + $kind, + $name, + implode( ', ', $unregistered_ids ) + ), + '7.2.0' + ); + } + + if ( $ids ) { + $this->add_field_module( $kind, $name, $ids, $script_module ); + } + + return $ids; + } + + /** + * Unregisters fields of an entity. + * + * Unregistering a field removes its registered definition and + * drops it from the script modules that applied to it (a module left with + * no field is forgotten). Unregistering every field forgets the entity: + * its registered fields and script modules. + * + * Like register(), it only runs on the `wp_entity_fields_api_init` action, and an + * invalid entity is reported and unregisters nothing. An id that is not a + * non-empty string is reported and skipped, like a definition register() + * cannot store. + * + * @since 7.2.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @param string[]|null $ids The ids of the fields to unregister. Default + * null, every field of the entity. + * @return array[] The list of the definitions unregistered, in + * registration order, as get_registered() returns them. + * Empty when none of the fields is registered, when + * called outside the `wp_entity_fields_api_init` action, or when + * the entity is invalid. + */ + public function unregister( $kind, $name, $ids = null ) { + if ( ! $this->doing_entity_fields_api_init( __METHOD__ ) || ! $this->validate_entity( __METHOD__, $kind, $name ) ) { + return array(); + } + + if ( null === $ids ) { + $unregistered = array_values( $this->fields[ $kind ][ $name ] ?? array() ); + unset( $this->fields[ $kind ][ $name ], $this->field_modules[ $kind ][ $name ] ); + $this->forget_empty_entity( $kind, $name ); + return $unregistered; + } + + $ids = $this->skip_invalid_ids( __METHOD__, $kind, $name, (array) $ids ); + if ( ! $ids ) { + return array(); + } + + $unregistered = array_intersect_key( $this->fields[ $kind ][ $name ] ?? array(), array_flip( $ids ) ); + if ( empty( $unregistered ) ) { + return array(); + } + + // A script module only ever applies to registered fields: register() + // and update() add it for fields they store, and fields leave both + // lists together here. So the fields removed are all the registry + // loses, and the modules are not returned: the ones that applied to + // them are listed by get_registered_field_modules(), read beforehand. + $ids = array_keys( $unregistered ); + foreach ( $ids as $id ) { + unset( $this->fields[ $kind ][ $name ][ $id ] ); + } + foreach ( $this->field_modules[ $kind ][ $name ] ?? array() as $module => $module_ids ) { + $remaining = array_values( array_diff( $module_ids, $ids ) ); + if ( empty( $remaining ) ) { + unset( $this->field_modules[ $kind ][ $name ][ $module ] ); + } else { + $this->field_modules[ $kind ][ $name ][ $module ] = $remaining; + } + } + $this->forget_empty_entity( $kind, $name ); + + return array_values( $unregistered ); + } + + /** + * Registers the fields of a field collection. + * + * A collection is a folder whose `index.php` returns its configuration, with + * a folder per field whose `field.php` returns the serializable part of the + * field (see get_collection_fields()). Each file is required without + * sharing the variables of the caller, see require_file(). The JavaScript + * parts of the fields, if any, ship in the script module of the + * collection. The configuration is an array with these keys: + * + * - `origin` (required): who registers the fields: `core`, or the slug of + * the plugin or theme. See register(). + * - `kind` (required): the entity kind, e.g. `postType`. + * - `name` (required): the entity name, e.g. `wp_template`. + * - `module` (optional): the id of the script module providing the + * JavaScript parts of the fields. Every field of the collection is + * registered with it. + * + * The collection registers its fields on the entity, after the fields + * registered on it before, in the alphabetical order of their folders. + * register() skips a field already registered for the entity, and + * registers the rest of the collection. Like register(), it does not check + * that the entity exists: the `/wp/v2/entity-fields` route only serves the fields + * of the entities the REST API exposes. + * + * Fields that apply to several entities, such as the defaults every post + * type derives from its supports, are registered in code: a + * `wp_entity_fields_api_init` callback can read their definitions from files with + * get_collection_fields() and register them on each entity with + * register(). + * + * Like register(), it only runs on the `wp_entity_fields_api_init` action. An + * invalid configuration is reported with _doing_it_wrong() and skipped. + * + * @since 7.2.0 + * + * @param string $directory The directory of the collection. + * @return bool Whether the collection is valid and all of its fields are + * registered. True as well when it has no fields; false when + * called outside the `wp_entity_fields_api_init` action. + */ + public function register_collection( $directory ) { + if ( ! $this->doing_entity_fields_api_init( __METHOD__ ) ) { + return false; + } + + $collection = $this->get_collection( __METHOD__, $directory ); + if ( null === $collection ) { + return false; + } + + if ( empty( $collection['fields'] ) ) { + return true; + } + $registered = $this->register( $collection['origin'], $collection['kind'], $collection['name'], $collection['fields'], $collection['module'] ); + + return count( $registered ) === count( $collection['fields'] ); + } + + /** + * Returns the fields of a field collection. + * + * A collection holds a folder per field, whose `field.php` returns the + * serializable part of the field, see register_collection(). + * + * @since 7.2.0 + * + * @param string $directory The directory of the collection. + * @return array The fields of the collection, keyed by id, in + * the alphabetical order of their folders. The + * id of a field is the name of its folder, + * unless its `field.php` sets an `id`, and it + * comes first in the field. + */ + public function get_collection_fields( $directory ) { + $files = glob( $directory . '/*/field.php' ); + if ( empty( $files ) ) { + return array(); + } + sort( $files, SORT_STRING ); + + $fields = array(); + foreach ( $files as $file ) { + $field = self::require_file( $file ); + if ( ! is_array( $field ) ) { + continue; + } + // The id comes first, like in the fields registered directly. + $field = array_merge( array( 'id' => basename( dirname( $file ) ) ), $field ); + $fields[ $field['id'] ] = $field; + } + + return $fields; + } + + /** + * Returns the fields registered for an entity. + * + * @since 7.2.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return array[] The list of field definitions, in registration order. + */ + public function get_registered( $kind, $name ) { + $this->initialize(); + return array_values( $this->fields[ $kind ][ $name ] ?? array() ); + } + + /** + * Returns the fields registered for every entity. + * + * @since 7.2.0 + * + * @return array> The lists of field + * definitions, keyed by + * kind, then by name. + */ + public function get_all_registered() { + $this->initialize(); + return array_map( + static function ( $entities ) { + return array_map( 'array_values', $entities ); + }, + $this->fields + ); + } + + /** + * Returns the script modules registered for an entity. + * + * @since 7.2.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return array The ids of the fields each module + * applies to, keyed by module id, in + * registration order. + */ + public function get_registered_field_modules( $kind, $name ) { + $this->initialize(); + return $this->field_modules[ $kind ][ $name ] ?? array(); + } + + /** + * Returns the ids of the script modules registered for every entity. + * + * @since 7.2.0 + * + * @return array> The lists of module ids, + * keyed by kind, then by + * name. + */ + public function get_all_registered_field_modules() { + $this->initialize(); + return array_map( + static function ( $entities ) { + return array_map( 'array_keys', $entities ); + }, + $this->field_modules + ); + } + + /** + * Fires the `wp_entity_fields_api_init` action the first time the registry + * is read. + * + * The read is refused until `init` has completed. The registry holds the + * fields of post types, and post types are not known until then: core + * registers its own on `init` at priority 0, plugins theirs at the default + * priority, and supports are added and removed on `init` too. The default + * fields of a post type derive from its supports, and the action fires + * once, so a read before or during `init` would fix the defaults from the + * post types registered so far for the rest of the request. + * + * did_action() is true from the first `init` callback on, hence the + * doing_action() check for the reads made while it runs. + * + * @since 7.2.0 + */ + protected function initialize() { + if ( $this->initialized ) { + return; + } + + if ( ! did_action( 'init' ) || doing_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The registered fields cannot be read until the `init` action has completed: the post types and supports they derive from are registered on `init`. Read them once `init` has completed, or hook `wp_entity_fields_api_init`.' ), + '7.2.0' + ); + return; + } + + // Set before firing, so a callback reading the registry to inspect + // the fields it updates does not fire the action again. + $this->initialized = true; + + /** + * Fires the first time the registered fields are read, after `init`. + * + * Register or adjust fields here, with wp_register_entity_fields(), + * wp_update_entity_fields(), and wp_unregister_entity_fields(): it is the only + * time they run. + * + * The first read happens wherever the fields are needed: while + * handling a REST request as well as on `admin_footer` of the pages + * that load the editor script. A callback should register fields and + * nothing else; script modules, styles, and their enqueue hooks + * belong on `init`. + * + * @since 7.2.0 + */ + do_action( 'wp_entity_fields_api_init' ); + } + + /** + * Checks that the `wp_entity_fields_api_init` action is firing, as register(), + * update(), and unregister() require. + * + * Outside the action a registration would either come before the + * defaults, which could then not be registered, or after the fields have been read + * and the import map of the editor script built from them. Refusing + * keeps the registry immutable once read. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @return bool Whether the action is firing. + */ + protected function doing_entity_fields_api_init( $method ) { + if ( doing_action( 'wp_entity_fields_api_init' ) ) { + return true; + } + + _doing_it_wrong( + $method, + __( 'Fields can only be registered, updated, and unregistered on the `wp_entity_fields_api_init` action.' ), + '7.2.0' + ); + return false; + } + + /** + * Reports the fields whose `type` is not one the REST schema of the + * fields lists, with _doing_it_wrong(). Those are the types DataViews + * provides a control for: a misspelt type is otherwise silent, the field + * renders without a control. A missing `type` is fine. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param array[] $fields The field definitions. + */ + protected function report_unknown_types( $method, $kind, $name, $fields ) { + $type_schema = ( new WP_REST_Entity_Fields_Controller() )->get_item_schema()['properties']['fields']['items']['properties']['type']; + $unknown = array(); + foreach ( $fields as $field ) { + if ( ! isset( $field['type'] ) ) { + continue; + } + $valid = rest_validate_value_from_schema( $field['type'], $type_schema, 'type' ); + if ( is_wp_error( $valid ) ) { + $unknown[] = sprintf( '%s (%s)', $field['id'], $valid->get_error_message() ); + } + } + if ( ! $unknown ) { + return; + } + _doing_it_wrong( + $method, + sprintf( + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids, each with the error of its type. */ + __( 'These fields of %1$s "%2$s" have a type DataViews does not provide, so they render without a control: %3$s' ), + $kind, + $name, + implode( ', ', $unknown ) + ), + '7.2.0' + ); + } + + /** + * Validates the arguments of register() and update(). + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param mixed $origin The origin. + * @param mixed $kind The entity kind. + * @param mixed $name The entity name. + * @param mixed $fields The field definitions. + * @param mixed $script_module The script module id, if any. + * @return bool Whether the call may proceed: the `wp_entity_fields_api_init` action + * is firing and the arguments are valid. + */ + protected function validate_arguments( $method, $origin, $kind, $name, $fields, $script_module ) { + if ( ! $this->doing_entity_fields_api_init( $method ) ) { + return false; + } + + if ( ! is_string( $origin ) || '' === $origin ) { + _doing_it_wrong( + $method, + __( 'The origin must be a non-empty string.' ), + '7.2.0' + ); + return false; + } + + if ( ! $this->validate_entity( $method, $kind, $name ) ) { + return false; + } + + if ( ! is_array( $fields ) || empty( $fields ) || ! array_is_list( $fields ) ) { + _doing_it_wrong( + $method, + __( 'The fields must be a list of non-empty field definitions.' ), + '7.2.0' + ); + return false; + } + + if ( null !== $script_module && ( ! is_string( $script_module ) || '' === $script_module ) ) { + _doing_it_wrong( + $method, + __( 'The script module must be the id of a script module.' ), + '7.2.0' + ); + return false; + } + + return true; + } + + /** + * Checks that the entity kind and name are non-empty strings, reporting + * them with _doing_it_wrong() otherwise. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param mixed $kind The entity kind. + * @param mixed $name The entity name. + * @return bool Whether both are valid. + */ + protected function validate_entity( $method, $kind, $name ) { + foreach ( array( $kind, $name ) as $argument ) { + if ( ! is_string( $argument ) || '' === $argument ) { + _doing_it_wrong( + $method, + __( 'The entity kind and the entity name must be non-empty strings.' ), + '7.2.0' + ); + return false; + } + } + return true; + } + + /** + * Drops the definitions that are not an array with a non-empty string + * `id`, reporting their positions in the call with _doing_it_wrong(): + * like a duplicated field, an invalid definition does not cost the call + * its other fields. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param array[] $fields The list of field definitions. + * @return array[] The valid definitions, in the order of the call. + */ + protected function skip_invalid_definitions( $method, $kind, $name, $fields ) { + $valid = array(); + $invalid = array(); + foreach ( $fields as $position => $field ) { + if ( is_array( $field ) && ! empty( $field['id'] ) && is_string( $field['id'] ) ) { + $valid[] = $field; + } else { + $invalid[] = '#' . ( $position + 1 ); + } + } + + if ( $invalid ) { + _doing_it_wrong( + $method, + sprintf( + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of positions, e.g. #2, #4. */ + __( 'Every field definition must be an array with a non-empty string `id`. These definitions of %1$s "%2$s" are skipped: %3$s. The rest of the fields of the call are stored.' ), + $kind, + $name, + implode( ', ', $invalid ) + ), + '7.2.0' + ); + } + + return $valid; + } + + /** + * Drops the ids that are not a non-empty string, reporting their + * positions in the call with _doing_it_wrong(): like an invalid + * definition, an invalid id does not cost the call its other ids. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param array $ids The ids of the fields. + * @return string[] The valid ids, in the order of the call. + */ + protected function skip_invalid_ids( $method, $kind, $name, $ids ) { + $valid = array(); + $invalid = array(); + foreach ( array_values( $ids ) as $position => $id ) { + if ( is_string( $id ) && '' !== $id ) { + $valid[] = $id; + } else { + $invalid[] = '#' . ( $position + 1 ); + } + } + + if ( $invalid ) { + _doing_it_wrong( + $method, + sprintf( + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of positions, e.g. #2, #4. */ + __( 'Every field id must be a non-empty string. These ids of %1$s "%2$s" are skipped: %3$s. The rest of the fields of the call are unregistered.' ), + $kind, + $name, + implode( ', ', $invalid ) + ), + '7.2.0' + ); + } + + return $valid; + } + + /** + * Reads and validates a field collection, see register_collection() for + * its format. + * + * An invalid configuration is reported with _doing_it_wrong(): the + * collection is skipped. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param string $directory The directory of the collection. + * @return array|null The configuration of the collection, with the `module` + * key set and its `fields` as a list, or null when the + * configuration is invalid. + */ + protected function get_collection( $method, $directory ) { + $slug = basename( $directory ); + $file = $directory . '/index.php'; + $config = is_file( $file ) ? self::require_file( $file ) : null; + $error = null; + + if ( ! is_array( $config ) ) { + $error = __( 'The `index.php` of a field collection must return an array.' ); + } elseif ( ! isset( $config['origin'] ) || ! is_string( $config['origin'] ) || '' === $config['origin'] ) { + $error = __( 'The `origin` of a field collection must be a non-empty string.' ); + } elseif ( ! isset( $config['kind'] ) || ! is_string( $config['kind'] ) || '' === $config['kind'] ) { + $error = __( 'The `kind` of a field collection must be a non-empty string.' ); + } elseif ( ! isset( $config['name'] ) || ! is_string( $config['name'] ) || '' === $config['name'] ) { + $error = __( 'The `name` of a field collection must be a non-empty string.' ); + } elseif ( isset( $config['module'] ) && ( ! is_string( $config['module'] ) || '' === $config['module'] ) ) { + $error = __( 'The `module` of a field collection must be the id of a script module.' ); + } + + if ( null !== $error ) { + _doing_it_wrong( + $method, + /* translators: 1: The name of a field collection. 2: The error. */ + sprintf( __( 'The field collection "%1$s" is skipped. %2$s' ), $slug, $error ), + '7.2.0' + ); + return null; + } + + return array_merge( + $config, + array( + 'module' => $config['module'] ?? null, + 'fields' => array_values( $this->get_collection_fields( $directory ) ), + ) + ); + } + + /** + * Requires a file without sharing the variables of the caller: this is + * about variable scope. + * + * @since 7.2.0 + * + * @param string $file The path of the file. + * @return mixed The return value of the file. + */ + protected static function require_file( $file ) { + return require $file; + } + + /** + * Applies a script module to fields of an entity, after the fields it + * already applies to. + * + * @since 7.2.0 + * + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param string[] $ids The ids of the fields. + * @param string|null $script_module The id of the script module, if any. + */ + protected function add_field_module( $kind, $name, $ids, $script_module ) { + if ( null === $script_module ) { + return; + } + + $module_ids = $this->field_modules[ $kind ][ $name ][ $script_module ] ?? array(); + foreach ( $ids as $id ) { + if ( ! in_array( $id, $module_ids, true ) ) { + $module_ids[] = $id; + } + } + $this->field_modules[ $kind ][ $name ][ $script_module ] = $module_ids; + } + + /** + * Forgets an entity left without fields or script modules, and its kind + * once no entity of the kind is left, so the registry only lists + * entities that have some. + * + * @since 7.2.0 + * + * @param string $kind The entity kind. + * @param string $name The entity name. + */ + protected function forget_empty_entity( $kind, $name ) { + if ( empty( $this->fields[ $kind ][ $name ] ) ) { + unset( $this->fields[ $kind ][ $name ] ); + } + if ( empty( $this->fields[ $kind ] ) ) { + unset( $this->fields[ $kind ] ); + } + if ( empty( $this->field_modules[ $kind ][ $name ] ) ) { + unset( $this->field_modules[ $kind ][ $name ] ); + } + if ( empty( $this->field_modules[ $kind ] ) ) { + unset( $this->field_modules[ $kind ] ); + } + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 6a635df738812..d378939409573 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -856,4 +856,7 @@ // Entity Actions API. add_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script', 10, 0 ); +// Entity Fields API. +add_action( 'admin_footer', '_wp_add_entity_field_modules_to_editor_script', 10, 0 ); + unset( $filter, $action, $post_type ); diff --git a/src/wp-includes/entity-fields.php b/src/wp-includes/entity-fields.php new file mode 100644 index 0000000000000..67578ac5020dd --- /dev/null +++ b/src/wp-includes/entity-fields.php @@ -0,0 +1,254 @@ +register( $origin, $kind, $name, $fields, $script_module ); +} + +/** + * Updates registered fields of an entity, see + * {@see WP_Entity_Fields_Registry::update()}. + * + * It only runs on the `wp_entity_fields_api_init` action. + * + * @since 7.2.0 + * + * @param string $origin Who updates the fields: `core`, or + * the slug of the plugin or theme. + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @param array[] $fields The list of partial field definitions, + * each with the `id` of a registered field. + * @param string|null $script_module The id of the script module providing + * the JavaScript parts of the fields, if any. + * @return string[] The ids of the fields updated, in the order of the + * call. Empty when none is. + */ +function wp_update_entity_fields( $origin, $kind, $name, $fields, $script_module = null ) { + return WP_Entity_Fields_Registry::get_instance()->update( $origin, $kind, $name, $fields, $script_module ); +} + +/** + * Unregisters fields of an entity, see + * {@see WP_Entity_Fields_Registry::unregister()}. + * + * It only runs on the `wp_entity_fields_api_init` action. + * + * @since 7.2.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @param string[]|null $ids The ids of the fields to unregister. Default + * null, every field of the entity. + * @return array[] The list of the definitions unregistered, in + * registration order. Empty when none is. + */ +function wp_unregister_entity_fields( $kind, $name, $ids = null ) { + return WP_Entity_Fields_Registry::get_instance()->unregister( $kind, $name, $ids ); +} + +/** + * Returns the fields registered for the given entity. + * + * @since 7.2.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return array[] The list of registered field definitions, in registration + * order. Empty until the `init` action has completed: a + * read before then is refused. + */ +function wp_get_registered_entity_fields( $kind, $name ) { + return WP_Entity_Fields_Registry::get_instance()->get_registered( $kind, $name ); +} + +/** + * Returns the ids of the script modules registered for the given entity. + * + * @since 7.2.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return array The ids of the fields each module applies + * to, keyed by module id, in registration + * order. Empty until the `init` action has + * completed: a read before then is refused. + */ +function wp_get_registered_entity_field_modules( $kind, $name ) { + return WP_Entity_Fields_Registry::get_instance()->get_registered_field_modules( $kind, $name ); +} + +/** + * Returns the ids of all script modules registered for any entity. + * + * @since 7.2.0 + * + * @return array> The lists of module ids, + * keyed by kind, then by + * name. Empty until the + * `init` action has + * completed: a read before + * then is refused. + */ +function wp_get_all_registered_entity_field_modules() { + return WP_Entity_Fields_Registry::get_instance()->get_all_registered_field_modules(); +} + +/** + * Declares the script modules of the entity fields as dependencies of the + * editor script. + * + * The editor imports the script modules of an entity on demand, with a dynamic + * `import()` the browser resolves through the import map of the page. Listing + * the modules as dynamic `module_dependencies` of the `wp-editor` script adds + * them to the import map of every page that loads the editor script: the post + * editor, the site editor, and the pages booted by `@wordpress/boot`, whose + * prerequisites depend on it. A dynamic dependency is only listed in the + * import map; the module is fetched when it is imported. + * + * Reading the registry fires `wp_entity_fields_api_init`, so the fields and their + * script modules are registered on demand. It only reads the registry on the + * pages that load the editor script, not on every admin request (heartbeat, + * Ajax, other screens). It runs on `admin_footer`, the last action before the + * import map is printed that fires both in the admin template and in the pages + * rendered outside it, which print the import map themselves. By then `init` + * has completed, so the supports the default fields derive from are final. + * The registry only accepts registrations while the action fires, so the + * modules declared here are the modules of every field the page can show. + * + * It is hooked to `admin_footer` without arguments, so the global scripts + * registry is used. In other scenarios (tests), the scripts are given directly. + * + * @since 7.2.0 + * @access private + * + * @param WP_Scripts|null $scripts Optional. The scripts registry. Default null, + * the global one. + */ +function _wp_add_entity_field_modules_to_editor_script( $scripts = null ) { + if ( ! $scripts instanceof WP_Scripts ) { + $scripts = wp_scripts(); + } + + /* + * The fields are loaded by the editor package, see + * packages/editor/src/dataviews/store/private-resolvers.ts in Gutenberg, + * which means any screen that wants to use this mechanism needs to load + * the editor script. + */ + if ( ! $scripts->query( 'wp-editor', 'enqueued' ) && ! $scripts->query( 'wp-editor', 'done' ) ) { + return; + } + + $kinds = wp_get_all_registered_entity_field_modules(); + if ( empty( $kinds ) ) { + return; + } + + $dependencies = $scripts->get_data( 'wp-editor', 'module_dependencies' ); + $dependencies = is_array( $dependencies ) ? $dependencies : array(); + + $declared = array(); + foreach ( $dependencies as $dependency ) { + $declared[] = is_array( $dependency ) ? $dependency['id'] : $dependency; + } + + $added = false; + foreach ( $kinds as $entities ) { + foreach ( $entities as $modules ) { + foreach ( $modules as $module ) { + if ( ! in_array( $module, $declared, true ) ) { + $dependencies[] = array( + 'id' => $module, + 'import' => 'dynamic', + ); + $declared[] = $module; + $added = true; + } + } + } + } + + if ( $added ) { + // `add_data()` replaces the value, hence the merge above. + $scripts->add_data( 'wp-editor', 'module_dependencies', $dependencies ); + } +} + +/** + * Returns the fields of a field collection, see + * {@see WP_Entity_Fields_Registry::get_collection_fields()}. + * + * @since 7.2.0 + * + * @param string $directory The directory of the collection. + * @return array The fields of the collection, keyed by id, in + * the alphabetical order of their folders. + */ +function wp_get_entity_field_collection_fields( $directory ) { + return WP_Entity_Fields_Registry::get_instance()->get_collection_fields( $directory ); +} + +/** + * Registers the fields of a field collection, see + * {@see WP_Entity_Fields_Registry::register_collection()} for the format of a + * collection. + * + * Like wp_register_entity_fields(), it only runs on the `wp_entity_fields_api_init` + * action. + * + * @since 7.2.0 + * + * @param string $directory The directory of the collection. + * @return bool Whether the collection is valid and all of its fields are + * registered. True as well when it has no fields. + */ +function wp_register_entity_field_collection( $directory ) { + return WP_Entity_Fields_Registry::get_instance()->register_collection( $directory ); +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php new file mode 100644 index 0000000000000..6f39b906a1182 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php @@ -0,0 +1,398 @@ +namespace = 'wp/v2'; + $this->rest_base = 'entity-fields'; + } + + /** + * Registers the routes for the controller. + * + * @since 7.2.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 the fields of an entity. + * + * @since 7.2.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 ) { + $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_entity_fields_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 the fields of this entity.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Resolves the capability required to read the fields of an entity. + * + * Mirrors the view config endpoint, which gates the same entities: 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`, so fields registered for a + * custom entity kind stay readable behind a baseline capability. + * + * @since 7.2.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 fields, or null if + * the entity is not registered. + */ + protected 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 fields registered for the given entity type. + * + * @since 7.2.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' ); + + $field_schema = $this->get_field_schema(); + $fields = array(); + foreach ( wp_get_registered_entity_fields( $kind, $name ) as $field ) { + $fields[] = _rest_cast_empty_objects_from_schema( $field, $field_schema ); + } + + $script_modules = array(); + foreach ( wp_get_registered_entity_field_modules( $kind, $name ) as $module => $field_ids ) { + $script_modules[] = array( + 'id' => $module, + 'fields' => array_values( $field_ids ), + ); + } + + $response = array( + 'kind' => $kind, + 'name' => $name, + 'fields' => $fields, + 'script_modules' => $script_modules, + ); + + return rest_ensure_response( $response ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @since 7.2.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'entity-fields', + 'type' => 'object', + 'properties' => array( + 'kind' => array( + 'description' => __( 'Entity kind.' ), + 'type' => 'string', + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Entity name.' ), + 'type' => 'string', + 'readonly' => true, + ), + 'fields' => array( + 'description' => __( 'The fields registered for the entity, in registration order.' ), + 'type' => 'array', + 'readonly' => true, + 'items' => $this->get_field_schema(), + ), + 'script_modules' => array( + 'description' => __( 'The script modules providing the JavaScript parts of the fields, each with the ids of the fields it applies to.' ), + 'type' => 'array', + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'The id of the script module.' ), + 'type' => 'string', + ), + 'fields' => array( + 'description' => __( 'The ids of the fields the script module applies to.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Returns the schema for a field definition. + * + * Describes the serializable subset of the DataViews Field API, see + * packages/dataviews/src/types/field-api.ts in Gutenberg. The properties + * that are JavaScript callbacks or components (`render`, `sort`, + * `getValue`, …) come from the script modules and are not part of the + * definition. + * Additional properties are allowed, so a plugin can register a field + * with properties of its own that its script module understands. + * + * @since 7.2.0 + * + * @return array Schema for a field definition. + */ + protected function get_field_schema() { + $option_schema = array( + 'type' => 'object', + 'properties' => array( + 'value' => array( + 'description' => __( 'The value of the option.' ), + 'type' => array( 'string', 'integer', 'number', 'boolean', 'null' ), + ), + 'label' => array( + 'description' => __( 'The label of the option.' ), + 'type' => 'string', + ), + 'description' => array( + 'description' => __( 'The description of the option.' ), + 'type' => 'string', + ), + ), + ); + + return array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'The unique identifier of the field.' ), + 'type' => 'string', + ), + 'origin' => array( + 'description' => __( 'Who registered and updated the field: `core`, or the slug of a plugin or theme.' ), + 'type' => 'object', + 'properties' => array( + 'registeredBy' => array( + 'description' => __( 'Who registered the field.' ), + 'type' => 'string', + ), + 'updatedBy' => array( + 'description' => __( 'Who updated the field, in update order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + 'type' => array( + 'description' => __( 'The type of the field, one of the types DataViews provides a control for.' ), + 'type' => 'string', + 'enum' => array( 'array', 'boolean', 'color', 'date', 'datetime', 'email', 'integer', 'media', 'number', 'password', 'telephone', 'text', 'time', 'url' ), + ), + 'label' => array( + 'description' => __( 'The label of the field.' ), + 'type' => 'string', + ), + 'header' => array( + 'description' => __( 'The header of the field. Defaults to the label.' ), + 'type' => 'string', + ), + 'description' => array( + 'description' => __( 'The description of the field.' ), + 'type' => 'string', + ), + 'placeholder' => array( + 'description' => __( 'The placeholder of the field.' ), + 'type' => 'string', + ), + 'Edit' => array( + 'description' => __( 'The control used to edit the field: the name of a control, or a control configuration.' ), + 'type' => array( 'string', 'object' ), + ), + 'isValid' => array( + 'description' => __( 'The validation rules of the field.' ), + 'type' => 'object', + ), + 'isDisabled' => array( + 'description' => __( 'Whether the field is disabled.' ), + 'type' => 'boolean', + ), + 'enableSorting' => array( + 'description' => __( 'Whether the field is sortable.' ), + 'type' => 'boolean', + ), + 'enableGlobalSearch' => array( + 'description' => __( 'Whether the field is searchable.' ), + 'type' => 'boolean', + ), + 'enableHiding' => array( + 'description' => __( 'Whether the field can be hidden.' ), + 'type' => 'boolean', + ), + 'elements' => array( + 'description' => __( 'The options to pick from when using the field as a filter.' ), + 'type' => 'array', + 'items' => $option_schema, + ), + 'filterBy' => array( + 'description' => __( 'The filter configuration of the field, or false when the field cannot be filtered.' ), + 'type' => array( 'object', 'boolean' ), + 'properties' => array( + 'operators' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + 'beforeInc', + 'afterInc', + 'contains', + 'notContains', + 'startsWith', + 'between', + 'on', + 'notOn', + 'inThePast', + 'over', + ), + ), + ), + 'isPrimary' => array( + 'type' => 'boolean', + ), + ), + ), + 'readOnly' => array( + 'description' => __( 'Whether the field is read only.' ), + 'type' => 'boolean', + ), + 'format' => array( + 'description' => __( 'The display format of the field.' ), + 'type' => 'object', + ), + ), + 'additionalProperties' => true, + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 107e984a74cd1..98ecc5781917f 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -208,6 +208,8 @@ require ABSPATH . WPINC . '/view-config.php'; require ABSPATH . WPINC . '/class-wp-entity-actions-registry.php'; require ABSPATH . WPINC . '/entity-actions.php'; +require ABSPATH . WPINC . '/class-wp-entity-fields-registry.php'; +require ABSPATH . WPINC . '/entity-fields.php'; require ABSPATH . WPINC . '/https-detection.php'; require ABSPATH . WPINC . '/https-migration.php'; require ABSPATH . WPINC . '/class-wp-user-request.php'; @@ -382,6 +384,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icon-collections-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-view-config-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-entity-actions-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-entity-fields-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/data/entity-fields/collections/duplicate/book/authorship/field.php b/tests/phpunit/data/entity-fields/collections/duplicate/book/authorship/field.php new file mode 100644 index 0000000000000..36bdc785286f9 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/duplicate/book/authorship/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Book authorship', +); diff --git a/tests/phpunit/data/entity-fields/collections/duplicate/book/index.php b/tests/phpunit/data/entity-fields/collections/duplicate/book/index.php new file mode 100644 index 0000000000000..bcfd9e743a8dc --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/duplicate/book/index.php @@ -0,0 +1,12 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_book', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/book/index.php b/tests/phpunit/data/entity-fields/collections/invalid/book/index.php new file mode 100644 index 0000000000000..dde281f12f80f --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/book/index.php @@ -0,0 +1,12 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_book', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/book/subtitle/field.php b/tests/phpunit/data/entity-fields/collections/invalid/book/subtitle/field.php new file mode 100644 index 0000000000000..df0a8a7bbb7b1 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/book/subtitle/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Subtitle', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/no_name/index.php b/tests/phpunit/data/entity-fields/collections/invalid/no_name/index.php new file mode 100644 index 0000000000000..25bdfd16e5f52 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/no_name/index.php @@ -0,0 +1,11 @@ + 'fixture', + 'kind' => 'postType', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/no_name/skipped/field.php b/tests/phpunit/data/entity-fields/collections/invalid/no_name/skipped/field.php new file mode 100644 index 0000000000000..defb36b537d17 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/no_name/skipped/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Skipped', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/no_origin/index.php b/tests/phpunit/data/entity-fields/collections/invalid/no_origin/index.php new file mode 100644 index 0000000000000..aec77ce0876dc --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/no_origin/index.php @@ -0,0 +1,11 @@ + 'postType', + 'name' => 'test_book', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/no_origin/skipped/field.php b/tests/phpunit/data/entity-fields/collections/invalid/no_origin/skipped/field.php new file mode 100644 index 0000000000000..defb36b537d17 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/no_origin/skipped/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Skipped', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/null_name/index.php b/tests/phpunit/data/entity-fields/collections/invalid/null_name/index.php new file mode 100644 index 0000000000000..8693adc5d478d --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/null_name/index.php @@ -0,0 +1,12 @@ + 'fixture', + 'kind' => 'postType', + 'name' => null, +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid/null_name/skipped/field.php b/tests/phpunit/data/entity-fields/collections/invalid/null_name/skipped/field.php new file mode 100644 index 0000000000000..f023095d06886 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid/null_name/skipped/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Skipped', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/book/index.php b/tests/phpunit/data/entity-fields/collections/valid/book/index.php new file mode 100644 index 0000000000000..983800034cd6b --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/book/index.php @@ -0,0 +1,13 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_book', + 'module' => 'fixture/book', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/book/subtitle/field.php b/tests/phpunit/data/entity-fields/collections/valid/book/subtitle/field.php new file mode 100644 index 0000000000000..64741cc03f3de --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/book/subtitle/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Subtitle', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/hidden/index.php b/tests/phpunit/data/entity-fields/collections/valid/hidden/index.php new file mode 100644 index 0000000000000..0e26cc09aff6d --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/hidden/index.php @@ -0,0 +1,12 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_hidden', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/hidden/secret/field.php b/tests/phpunit/data/entity-fields/collections/valid/hidden/secret/field.php new file mode 100644 index 0000000000000..5a4b582055785 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/hidden/secret/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Secret', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/magazine/index.php b/tests/phpunit/data/entity-fields/collections/valid/magazine/index.php new file mode 100644 index 0000000000000..cf5d537fa43a6 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/magazine/index.php @@ -0,0 +1,12 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_magazine', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/magazine/issue/field.php b/tests/phpunit/data/entity-fields/collections/valid/magazine/issue/field.php new file mode 100644 index 0000000000000..0fa38083d49e4 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/magazine/issue/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Issue', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/scoped/index.php b/tests/phpunit/data/entity-fields/collections/valid/scoped/index.php new file mode 100644 index 0000000000000..c21f08548fae0 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/scoped/index.php @@ -0,0 +1,15 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_' . $slug, +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/scoped/plain/field.php b/tests/phpunit/data/entity-fields/collections/valid/scoped/plain/field.php new file mode 100644 index 0000000000000..77b4f5dc1a983 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/scoped/plain/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Plain', +); diff --git a/tests/phpunit/data/entity-fields/collections/valid/scoped/scoped/field.php b/tests/phpunit/data/entity-fields/collections/valid/scoped/scoped/field.php new file mode 100644 index 0000000000000..9556db00de9a3 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/valid/scoped/scoped/field.php @@ -0,0 +1,23 @@ + $value, + 'label' => ucfirst( $value ), + ); +} + +return array( + 'type' => 'text', + 'label' => ucfirst( $file ), + 'elements' => $elements, +); diff --git a/tests/phpunit/data/entity-fields/fixture/alpha/field.php b/tests/phpunit/data/entity-fields/fixture/alpha/field.php new file mode 100644 index 0000000000000..107e4ba141194 --- /dev/null +++ b/tests/phpunit/data/entity-fields/fixture/alpha/field.php @@ -0,0 +1,12 @@ + 'zeta', + 'type' => 'text', + 'label' => 'Zeta', +); diff --git a/tests/phpunit/data/entity-fields/fixture/beta/field.php b/tests/phpunit/data/entity-fields/fixture/beta/field.php new file mode 100644 index 0000000000000..fcfeaa998bb43 --- /dev/null +++ b/tests/phpunit/data/entity-fields/fixture/beta/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Beta', +); diff --git a/tests/phpunit/tests/entity-fields/base.php b/tests/phpunit/tests/entity-fields/base.php new file mode 100644 index 0000000000000..b95d57897a7ce --- /dev/null +++ b/tests/phpunit/tests/entity-fields/base.php @@ -0,0 +1,198 @@ +callbacks as list( $callback, $priority ) ) { + remove_action( 'wp_entity_fields_api_init', $callback, $priority ); + } + $this->callbacks = array(); + remove_action( 'wp_entity_fields_api_init', array( static::class, 'register_default_fields' ), 0 ); + foreach ( $this->post_types as $post_type ) { + unregister_post_type( $post_type ); + } + $this->post_types = array(); + self::reset_registry(); + + parent::tear_down(); + } + + /** + * Registers the fixture defaults: fields of pages registered by `core` + * with a script module, standing in for the default fields of post types. + * + * Hooked to `wp_entity_fields_api_init` at priority 0 for every test. + */ + public static function register_default_fields() { + wp_register_entity_fields( + 'core', + 'postType', + 'page', + array( + array( + 'id' => 'author', + 'type' => 'integer', + 'label' => 'Author', + ), + array( + 'id' => 'date', + 'type' => 'datetime', + 'label' => 'Date', + ), + ), + 'test/defaults' + ); + } + + /** + * Resets the registry: drops the singleton instance, so its first read + * fires `wp_entity_fields_api_init` again. + */ + protected static function reset_registry() { + $instance = new ReflectionProperty( WP_Entity_Fields_Registry::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); + } + + /** + * Hooks a callback to `wp_entity_fields_api_init` for the duration of the test + * and fires the action anew, so the registry reflects it right away. + * + * Registering and unregistering only run on the action, so this is how a + * test alters the registry, as a plugin does. The registry is reset and + * read, which fires the action: the fixture defaults are registered again + * and the callbacks hooked so far replay, in order. The callbacks are + * removed on tear down. + * + * @param callable $callback The callback. + * @param int $priority The priority. Default 10, after the fixture defaults. + */ + protected function on_entity_fields_api_init( $callback, $priority = 10 ) { + add_action( 'wp_entity_fields_api_init', $callback, $priority ); + $this->callbacks[] = array( $callback, $priority ); + + self::reset_registry(); + WP_Entity_Fields_Registry::get_instance()->get_all_registered(); + } + + /** + * Registers fields on `wp_entity_fields_api_init` for the duration of the test, + * with the `test-plugin` origin: the registry is reset on tear down. + * + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param array[] $fields The field definitions. + * @param string|null $module The script module id, if any. + * @return string[] The ids of the fields registered. + */ + protected function register_fields( $kind, $name, $fields, $module = null ) { + $registered = array(); + $this->on_entity_fields_api_init( + static function () use ( &$registered, $kind, $name, $fields, $module ) { + $registered = wp_register_entity_fields( 'test-plugin', $kind, $name, $fields, $module ); + } + ); + return $registered; + } + + /** + * Registers the post types a test uses, exposed in the REST API unless + * hidden. They are unregistered on tear down. + * + * @param array $post_types The supports, keyed by post type. + * @param string[] $hidden The post types not exposed in + * the REST API. + */ + protected function register_post_types( $post_types, $hidden = array() ) { + foreach ( $post_types as $post_type => $supports ) { + $this->post_types[] = $post_type; + register_post_type( + $post_type, + array( + 'show_in_rest' => ! in_array( $post_type, $hidden, true ), + 'supports' => $supports, + ) + ); + } + } + + /** + * Records the `_doing_it_wrong()` notices a method reports, so a test can + * assert what the notice names. + * + * @param string $method The method whose notices to record. + * @return array A list that fills with the reported messages. + */ + protected function &record_notices( $method ) { + $reported = array(); + add_action( + 'doing_it_wrong_run', + static function ( $function_name, $message ) use ( &$reported, $method ) { + if ( $method === $function_name ) { + $reported[] = $message; + } + }, + 10, + 2 + ); + + return $reported; + } + + /** + * Builds a minimal field definition. + * + * @param string $id The field id. + * @return array The field definition. + */ + protected function field( $id ) { + return array( + 'id' => $id, + 'type' => 'text', + 'label' => $id, + ); + } +} diff --git a/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php b/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php new file mode 100644 index 0000000000000..8dc91728ff123 --- /dev/null +++ b/tests/phpunit/tests/entity-fields/wpAddEntityFieldModulesToEditorScript.php @@ -0,0 +1,282 @@ +on_entity_fields_api_init( + static function () { + foreach ( wp_get_all_registered_entity_field_modules() as $kind => $entities ) { + foreach ( array_keys( $entities ) as $name ) { + wp_unregister_entity_fields( $kind, $name ); + } + } + } + ); + } + + /** + * Returns the module ids among the module dependencies of a script. + * + * @param WP_Scripts $scripts The scripts registry. + * @param string $handle The script handle. + * @return string[] The module ids, in order. + */ + private function get_module_dependency_ids( WP_Scripts $scripts, $handle ) { + $dependencies = $scripts->get_data( $handle, 'module_dependencies' ); + return array_map( + static function ( $dependency ) { + return is_array( $dependency ) ? $dependency['id'] : $dependency; + }, + is_array( $dependencies ) ? $dependencies : array() + ); + } + + /** + * The modules are added on `admin_footer`, which fires before the import + * map is printed, both in the admin template and in the pages rendered + * outside it. + * + * @ticket 66229 + */ + public function test_the_modules_are_added_on_admin_footer() { + $this->assertSame( 10, has_action( 'admin_footer', '_wp_add_entity_field_modules_to_editor_script' ) ); + $this->assertFalse( has_action( 'admin_init', '_wp_add_entity_field_modules_to_editor_script' ) ); + } + + /** + * The hook suffix `admin_footer` passes is not taken for the scripts + * registry: the global one is used. + * + * @ticket 66229 + */ + public function test_the_admin_footer_action_uses_the_global_scripts() { + global $wp_scripts; + $original_scripts = $wp_scripts; + $wp_scripts = new WP_Scripts(); + + try { + $this->unregister_all_field_modules(); + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ); + $wp_scripts->add( 'wp-editor', '/editor.js' ); + $wp_scripts->enqueue( 'wp-editor' ); + $wp_scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + + // Other callbacks of the action may print markup. + get_echo( 'do_action', array( 'admin_footer', 'site-editor.php' ) ); + + $ids = $this->get_module_dependency_ids( $wp_scripts, 'wp-editor' ); + } finally { + $wp_scripts = $original_scripts; + } + + $this->assertSame( array( 'plugin/color' ), $ids ); + } + + /** + * A page that registers the editor script without loading it is left + * untouched, and the registry is not read. + * + * @ticket 66229 + */ + public function test_nothing_happens_when_the_editor_script_is_not_enqueued() { + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + + $fired = did_action( 'wp_entity_fields_api_init' ); + self::reset_registry(); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertSame( array(), $scripts->get_data( 'wp-editor', 'module_dependencies' ) ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'The registry is not read.' ); + } + + /** + * The modules are added when the editor script is only a dependency of + * the scripts the page enqueues, as on the pages booted by + * `@wordpress/boot`. + * + * @ticket 66229 + */ + public function test_the_modules_are_added_when_the_editor_script_is_a_dependency() { + $this->unregister_all_field_modules(); + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ); + + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + $scripts->add( 'boot', '/boot.js', array( 'wp-editor' ) ); + $scripts->enqueue( 'boot' ); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertSame( array( 'plugin/color' ), $this->get_module_dependency_ids( $scripts, 'wp-editor' ) ); + } + + /** + * A scripts registry without the editor script is left untouched, even + * when field script modules are registered. + * + * @ticket 66229 + */ + public function test_nothing_happens_without_the_editor_script() { + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ); + + $scripts = new WP_Scripts(); + // The default scripts register the real editor script. + $scripts->remove( 'wp-editor' ); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertFalse( $scripts->query( 'wp-editor', 'registered' ) ); + $this->assertFalse( $scripts->get_data( 'wp-editor', 'module_dependencies' ) ); + } + + /** + * The dependencies of the editor script stay untouched when no entity has + * a script module registered. + * + * @ticket 66229 + */ + public function test_nothing_happens_without_registered_field_modules() { + $this->unregister_all_field_modules(); + $this->assertSame( array(), wp_get_all_registered_entity_field_modules(), 'No field script module is registered.' ); + + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->enqueue( 'wp-editor' ); + $scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertSame( array(), $scripts->get_data( 'wp-editor', 'module_dependencies' ) ); + } + + /** + * The script module of a field becomes a dynamic dependency of the editor + * script. + * + * @ticket 66229 + */ + public function test_field_script_modules_become_dynamic_dependencies_of_the_editor_script() { + $this->unregister_all_field_modules(); + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ); + + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->enqueue( 'wp-editor' ); + // Only the dependencies added by the function are of interest. + $scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertSame( + array( + array( + 'id' => 'plugin/color', + 'import' => 'dynamic', + ), + ), + $scripts->get_data( 'wp-editor', 'module_dependencies' ) + ); + } + + /** + * The dependencies already declared are kept, and the modules are added + * once: a second run adds nothing. + * + * @ticket 66229 + */ + public function test_declared_dependencies_are_kept_and_modules_are_added_once() { + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ); + + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->enqueue( 'wp-editor' ); + $scripts->add_data( 'wp-editor', 'module_dependencies', array( '@wordpress/existing' ) ); + + _wp_add_entity_field_modules_to_editor_script( $scripts ); + $dependencies = $scripts->get_data( 'wp-editor', 'module_dependencies' ); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $this->assertSame( $dependencies, $scripts->get_data( 'wp-editor', 'module_dependencies' ), 'A second run adds nothing.' ); + $ids = $this->get_module_dependency_ids( $scripts, 'wp-editor' ); + $this->assertSame( '@wordpress/existing', $ids[0], 'The declared dependency is kept first.' ); + $this->assertSame( 1, count( array_keys( $ids, 'plugin/color', true ) ), 'The module is declared once.' ); + $this->assertContains( + array( + 'id' => 'plugin/color', + 'import' => 'dynamic', + ), + $dependencies + ); + } + + /** + * A module registered for several entities is declared once. + * + * @ticket 66229 + */ + public function test_a_module_shared_by_several_entities_is_declared_once() { + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/fields' ); + $this->register_fields( 'taxonomy', 'category', array( $this->field( 'color' ) ), 'plugin/fields' ); + + $scripts = new WP_Scripts(); + $scripts->add( 'wp-editor', '/editor.js' ); + $scripts->enqueue( 'wp-editor' ); + // Only the dependencies added by the function are of interest. + $scripts->add_data( 'wp-editor', 'module_dependencies', array() ); + _wp_add_entity_field_modules_to_editor_script( $scripts ); + + $ids = $this->get_module_dependency_ids( $scripts, 'wp-editor' ); + $this->assertSame( array( 'plugin/fields' ), array_values( array_intersect( $ids, array( 'plugin/fields' ) ) ) ); + } + + /** + * The script modules of the fields reach the import map of a page that + * loads the editor script. + * + * @ticket 66229 + */ + public function test_field_script_modules_reach_the_import_map() { + global $wp_scripts; + $original_scripts = $wp_scripts; + $wp_scripts = new WP_Scripts(); + + try { + // Only the test module is of interest: the fixture defaults + // module is not registered as a script module. + $this->unregister_all_field_modules(); + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ); + $wp_scripts->add( 'wp-editor', '/editor.js' ); + wp_register_script_module( 'plugin/color', '/color.js' ); + + $wp_scripts->enqueue( 'wp-editor' ); + _wp_add_entity_field_modules_to_editor_script( $wp_scripts ); + + $processor = new WP_HTML_Tag_Processor( get_echo( array( wp_script_modules(), 'print_import_map' ) ) ); + $this->assertTrue( $processor->next_tag( 'SCRIPT' ), 'An import map is printed.' ); + $this->assertSame( 'importmap', $processor->get_attribute( 'type' ) ); + $import_map = json_decode( $processor->get_modifiable_text(), true ); + } finally { + $wp_scripts = $original_scripts; + wp_deregister_script_module( 'plugin/color' ); + } + + $this->assertArrayHasKey( 'plugin/color', $import_map['imports'] ); + } +} diff --git a/tests/phpunit/tests/entity-fields/wpEntityFieldsRegistry.php b/tests/phpunit/tests/entity-fields/wpEntityFieldsRegistry.php new file mode 100644 index 0000000000000..e91c910b81c2d --- /dev/null +++ b/tests/phpunit/tests/entity-fields/wpEntityFieldsRegistry.php @@ -0,0 +1,672 @@ +assertSame( $fired + 1, did_action( 'wp_entity_fields_api_init' ), 'The first read fires the action.' ); + + wp_get_registered_entity_fields( 'postType', 'post' ); + wp_get_registered_entity_field_modules( 'postType', 'page' ); + wp_get_all_registered_entity_field_modules(); + $registry->get_all_registered(); + $this->assertSame( $fired + 1, did_action( 'wp_entity_fields_api_init' ), 'Further reads do not fire it again.' ); + } + + /** + * A plugin hooking the action at the default priority sees the fields + * registered at an earlier priority: it can update a field, or remove it. + * + * @ticket 66229 + */ + public function test_a_callback_at_the_default_priority_alters_the_defaults() { + $this->on_entity_fields_api_init( + static function () { + wp_update_entity_fields( + 'test-plugin', + 'postType', + 'page', + array( + array( + 'id' => 'date', + 'enableSorting' => true, + ), + ) + ); + wp_unregister_entity_fields( 'postType', 'page', array( 'author' ) ); + } + ); + + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertArrayHasKey( 'date', $fields, 'The default field is kept.' ); + $this->assertTrue( $fields['date']['enableSorting'], 'The property is updated.' ); + $this->assertSame( 'datetime', $fields['date']['type'], 'The rest of the default definition is kept.' ); + $this->assertSame( + array( + 'registeredBy' => 'core', + 'updatedBy' => array( 'test-plugin' ), + ), + $fields['date']['origin'], + 'Updating a field keeps who registered it and records who updated it.' + ); + $this->assertArrayNotHasKey( 'author', $fields, 'The default field is removed.' ); + } + + /** + * The origin a field is registered with is stored on its definition, + * over any `origin` the definition sets. + * + * @ticket 66229 + */ + public function test_fields_carry_the_origin_they_are_registered_with() { + $this->register_fields( 'postType', 'page', array( $this->field( 'color' ) + array( 'origin' => 'spoofed' ) ) ); + + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'origin', 'id' ); + $this->assertSame( + array( + 'registeredBy' => 'test-plugin', + 'updatedBy' => array(), + ), + $fields['color'] + ); + $this->assertSame( 'core', $fields['author']['registeredBy'] ); + } + + /** + * A field can only be registered once: a field with the id of a + * registered field is skipped and leaves it untouched, and the rest of + * the fields of the call are registered. + * + * @ticket 66229 + */ + public function test_registering_a_registered_field_is_skipped() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::register' ); + $registered = $this->register_fields( + 'postType', + 'page', + array( + $this->field( 'color' ), + array( + 'id' => 'author', + 'label' => 'Writer', + ), + ), + 'plugin/fields' + ); + + $this->assertSame( array( 'color' ), $registered, 'The ids of the fields registered are returned.' ); + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertArrayHasKey( 'color', $fields, 'The rest of the fields of the call are registered.' ); + $this->assertSame( 'Author', $fields['author']['label'], 'The registered field is left untouched.' ); + $this->assertSame( 'core', $fields['author']['origin']['registeredBy'] ); + $this->assertSame( array( 'color' ), wp_get_registered_entity_field_modules( 'postType', 'page' )['plugin/fields'], 'The script module only applies to the fields registered.' ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( 'already registered: author', $reported[0], 'The notice names the field that is already registered, not the others of the call.' ); + } + + /** + * An id that appears more than once in a call is registered once, with + * its first definition, and the rest of the fields of the call are + * registered. + * + * @ticket 66229 + */ + public function test_registering_the_same_field_twice_keeps_the_first_definition() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::register' ); + $registered = $this->register_fields( + 'postType', + 'page', + array( + $this->field( 'color' ), + array( + 'id' => 'color', + 'label' => 'Colour', + ), + $this->field( 'size' ), + ) + ); + + $this->assertSame( array( 'color', 'size' ), $registered ); + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertSame( $this->field( 'color' )['label'], $fields['color']['label'], 'The first definition is kept.' ); + $this->assertArrayHasKey( 'size', $fields, 'The rest of the fields of the call are registered.' ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( 'more than once in the same call: color', $reported[0], 'The notice names the duplicated field.' ); + } + + /** + * A field unregistered can be registered anew, with a new origin. + * + * @ticket 66229 + */ + public function test_an_unregistered_field_can_be_registered_again() { + $registered = array(); + $this->on_entity_fields_api_init( + static function () use ( &$registered ) { + wp_unregister_entity_fields( 'postType', 'page', array( 'author' ) ); + $registered = wp_register_entity_fields( 'test-plugin', 'postType', 'page', array( array( 'id' => 'author' ) ) ); + } + ); + + $this->assertSame( array( 'author' ), $registered ); + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertSame( + array( + 'id' => 'author', + 'origin' => array( + 'registeredBy' => 'test-plugin', + 'updatedBy' => array(), + ), + ), + $fields['author'] + ); + } + + /** + * Entities are told apart by their kind and name, not by a key joining + * them: `a/b` + `c` and `a` + `b/c` are two entities, whose fields and + * script modules do not mix. + * + * @ticket 66229 + */ + public function test_entities_whose_kind_and_name_join_alike_are_kept_apart() { + $result = array(); + $this->on_entity_fields_api_init( + function () use ( &$result ) { + $result['first'] = wp_register_entity_fields( 'test-plugin', 'a/b', 'c', array( $this->field( 'color' ) ), 'plugin/first' ); + $result['second'] = wp_register_entity_fields( 'test-plugin', 'a', 'b/c', array( $this->field( 'color' ), $this->field( 'size' ) ), 'plugin/second' ); + $result['unregistered'] = wp_unregister_entity_fields( 'a/b', 'c' ); + } + ); + + $this->assertSame( array( 'color' ), $result['first'], 'The first entity registers its field.' ); + $this->assertSame( array( 'color', 'size' ), $result['second'], 'The second entity registers a field with the same id.' ); + $this->assertSame( array( 'color' ), array_column( $result['unregistered'], 'id' ), 'Unregistering the first entity only removes its own field.' ); + $this->assertSame( array(), wp_get_registered_entity_fields( 'a/b', 'c' ) ); + $this->assertSame( array( 'color', 'size' ), array_column( wp_get_registered_entity_fields( 'a', 'b/c' ), 'id' ), 'The second entity keeps its fields.' ); + $this->assertSame( array( 'plugin/second' => array( 'color', 'size' ) ), wp_get_registered_entity_field_modules( 'a', 'b/c' ), 'The second entity keeps only its own module.' ); + $this->assertSame( array( 'b/c' => array( 'plugin/second' ) ), wp_get_all_registered_entity_field_modules()['a'], 'Only the second entity has modules under its kind.' ); + $this->assertArrayNotHasKey( 'a/b', wp_get_all_registered_entity_field_modules(), 'The kind of the first entity, emptied, is forgotten.' ); + } + + /** + * Unregistering returns the definitions it removes, in registration + * order and with their origin, whatever the order of the ids asked for; + * ids not registered are ignored, and nothing is returned when none is. + * + * @ticket 66229 + */ + public function test_unregistering_returns_the_unregistered_fields() { + $unregistered = array(); + $this->on_entity_fields_api_init( + function () use ( &$unregistered ) { + wp_register_entity_fields( 'test-plugin', 'test', 'entity', array( $this->field( 'a' ), $this->field( 'b' ), $this->field( 'c' ) ), 'plugin/fields' ); + $unregistered['by_id'] = wp_unregister_entity_fields( 'test', 'entity', array( 'c', 'missing', 'a' ) ); + $unregistered['missing'] = wp_unregister_entity_fields( 'test', 'entity', array( 'a' ) ); + $unregistered['every'] = wp_unregister_entity_fields( 'test', 'entity' ); + $unregistered['empty'] = wp_unregister_entity_fields( 'test', 'entity' ); + } + ); + + $origin = array( + 'registeredBy' => 'test-plugin', + 'updatedBy' => array(), + ); + $this->assertSame( + array( + array_merge( $this->field( 'a' ), array( 'origin' => $origin ) ), + array_merge( $this->field( 'c' ), array( 'origin' => $origin ) ), + ), + $unregistered['by_id'], + 'The fields unregistered by id are returned in registration order.' + ); + $this->assertSame( array(), $unregistered['missing'], 'Nothing is returned when no field is registered.' ); + $this->assertSame( + array( array_merge( $this->field( 'b' ), array( 'origin' => $origin ) ) ), + $unregistered['every'], + 'Unregistering every field returns them all.' + ); + $this->assertSame( array(), $unregistered['empty'], 'Nothing is returned for an entity without fields.' ); + $this->assertSame( array(), wp_get_registered_entity_fields( 'test', 'entity' ), 'The entity has no fields left.' ); + $this->assertArrayNotHasKey( 'test', wp_get_all_registered_entity_field_modules(), 'The entity has no script module left.' ); + } + + /** + * Updating merges each definition into the registered field, keeping + * its position; the origin cannot be updated, and each origin that + * updates the field is recorded once, in update order. The script module + * applies to the field on top of the modules it has. + * + * @ticket 66229 + */ + public function test_updating_merges_into_the_registered_field() { + $updated = array(); + $this->on_entity_fields_api_init( + static function () use ( &$updated ) { + $update = array( + array( + 'id' => 'author', + 'label' => 'Writer', + 'origin' => 'spoofed', + ), + ); + $updated[] = wp_update_entity_fields( 'plugin-a', 'postType', 'page', $update, 'plugin/author' ); + $updated[] = wp_update_entity_fields( + 'plugin-b', + 'postType', + 'page', + array( + array( + 'id' => 'author', + 'readOnly' => true, + ), + ) + ); + $updated[] = wp_update_entity_fields( + 'plugin-a', + 'postType', + 'page', + array( + array( + 'id' => 'author', + 'label' => 'Byline', + ), + ) + ); + } + ); + + $this->assertSame( array( array( 'author' ), array( 'author' ), array( 'author' ) ), $updated ); + $fields = wp_get_registered_entity_fields( 'postType', 'page' ); + $this->assertSame( 'author', $fields[0]['id'], 'The field keeps its position.' ); + $this->assertSame( 'Byline', $fields[0]['label'], 'The last update wins.' ); + $this->assertTrue( $fields[0]['readOnly'], 'The updates add up.' ); + $this->assertSame( 'integer', $fields[0]['type'], 'The rest of the definition is kept.' ); + $this->assertSame( + array( + 'registeredBy' => 'core', + 'updatedBy' => array( 'plugin-a', 'plugin-b' ), + ), + $fields[0]['origin'] + ); + $modules = wp_get_registered_entity_field_modules( 'postType', 'page' ); + $this->assertContains( 'author', $modules['test/defaults'], 'The field keeps its modules.' ); + $this->assertSame( array( 'author' ), $modules['plugin/author'], 'The module applies to the field.' ); + } + + /** + * A field whose `type` DataViews does not provide renders without a + * control, so it is reported, and registered anyway. A field without a + * `type` is not reported. + * + * @ticket 66229 + */ + public function test_registering_a_field_with_an_unknown_type_is_reported() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::register' ); + $registered = $this->register_fields( + 'postType', + 'page', + array( + $this->field( 'color' ), + array( + 'id' => 'size', + 'type' => 'interger', + 'label' => 'Size', + ), + array( + 'id' => 'weight', + 'label' => 'Weight', + ), + ) + ); + + $this->assertSame( array( 'color', 'size', 'weight' ), $registered, 'The field is registered anyway.' ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( ': size (type is not one of ', $reported[0], 'The notice names the field and the error of its type, not the others of the call.' ); + } + + /** + * Only registered fields can be updated: a field that is not is skipped, + * and the rest of the fields of the call are updated. + * + * @ticket 66229 + */ + public function test_updating_an_unregistered_field_is_skipped() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::update' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::update' ); + $updated = array(); + $this->on_entity_fields_api_init( + static function () use ( &$updated ) { + $updated = wp_update_entity_fields( + 'test-plugin', + 'postType', + 'page', + array( + array( + 'id' => 'author', + 'label' => 'Writer', + ), + array( + 'id' => 'color', + 'label' => 'Color', + ), + ), + 'plugin/fields' + ); + } + ); + + $this->assertSame( array( 'author' ), $updated, 'The ids of the fields updated are returned.' ); + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertArrayNotHasKey( 'color', $fields, 'The field is not registered.' ); + $this->assertSame( 'Writer', $fields['author']['label'], 'The rest of the fields of the call are updated.' ); + $this->assertSame( array( 'test-plugin' ), $fields['author']['origin']['updatedBy'] ); + $this->assertSame( array( 'author' ), wp_get_registered_entity_field_modules( 'postType', 'page' )['plugin/fields'], 'The script module only applies to the fields updated.' ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( 'not registered: color', $reported[0], 'The notice names only the field that is not registered.' ); + } + + /** + * A definition that is not an array with a non-empty string `id` is + * reported by its position and skipped, like a duplicated field: the + * rest of the fields of the call are registered, or updated. + * + * @ticket 66229 + */ + public function test_an_invalid_definition_is_skipped() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::update' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::register' ); + $registered = null; + $updated = null; + $this->on_entity_fields_api_init( + function () use ( &$registered, &$updated ) { + $registered = wp_register_entity_fields( + 'test-plugin', + 'postType', + 'page', + array( + $this->field( 'color' ), + 'size', + array( 'label' => 'No id' ), + array( + 'id' => 7, + 'label' => 'Numeric id', + ), + $this->field( 'weight' ), + ) + ); + $updated = wp_update_entity_fields( + 'test-plugin', + 'postType', + 'page', + array( + array( 'label' => 'No id' ), + array( + 'id' => 'color', + 'label' => 'Colour', + ), + ) + ); + } + ); + + $this->assertSame( array( 'color', 'weight' ), $registered, 'The valid definitions are registered.' ); + $this->assertSame( array( 'color' ), $updated, 'The valid definitions are updated.' ); + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertSame( 'Colour', $fields['color']['label'] ); + $this->assertArrayHasKey( 'weight', $fields ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( 'skipped: #2, #3, #4', $reported[0], 'The notice names the positions of the invalid definitions.' ); + } + + /** + * An origin that is not a non-empty string is refused. + * + * @ticket 66229 + */ + public function test_registering_with_an_invalid_origin_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $registered = null; + $this->on_entity_fields_api_init( + static function () use ( &$registered ) { + $registered = wp_register_entity_fields( '', 'postType', 'page', array( array( 'id' => 'color' ) ) ); + } + ); + + $this->assertSame( array(), $registered ); + $this->assertNotContains( 'color', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ) ); + } + + /** + * Fields that are not a list, such as fields keyed by id, are refused. + * + * @ticket 66229 + */ + public function test_registering_fields_that_are_not_a_list_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $registered = null; + $this->on_entity_fields_api_init( + static function () use ( &$registered ) { + $registered = wp_register_entity_fields( 'test-plugin', 'postType', 'page', array( 'color' => array( 'id' => 'color' ) ) ); + } + ); + + $this->assertSame( array(), $registered ); + $this->assertNotContains( 'color', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ) ); + } + + /** + * Unregistering with an entity kind or name that is not a non-empty + * string is refused with a notice rather than a fatal error, and the + * registry is left untouched. + * + * @ticket 66229 + */ + public function test_unregistering_with_an_invalid_entity_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::unregister' ); + $results = array(); + $this->on_entity_fields_api_init( + static function () use ( &$results ) { + $results['no name'] = wp_unregister_entity_fields( 'postType', array( 'author' ) ); + $results['empty kind'] = wp_unregister_entity_fields( '', 'page', array( 'author' ) ); + } + ); + + $this->assertSame( + array( + 'no name' => array(), + 'empty kind' => array(), + ), + $results + ); + $this->assertContains( 'author', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ), 'The default field is kept.' ); + } + + /** + * An id that is not a non-empty string is reported and skipped: the + * other ids of the call are unregistered. + * + * @ticket 66229 + */ + public function test_unregistering_with_an_invalid_id_skips_it() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::unregister' ); + $unregistered = null; + $this->on_entity_fields_api_init( + static function () use ( &$unregistered ) { + $unregistered = wp_unregister_entity_fields( 'postType', 'page', array( array( 'id' => 'author' ), '', 'date' ) ); + } + ); + + $this->assertSame( array( 'date' ), array_column( $unregistered, 'id' ) ); + $ids = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ); + $this->assertContains( 'author', $ids, 'A definition in place of an id is skipped, not matched.' ); + $this->assertNotContains( 'date', $ids ); + } + + /** + * Registering only runs on the action: elsewhere it is refused and the + * registry is left untouched. Before the action a registration would + * keep the defaults from being registered; after it the fields have been + * read and the import map of the editor script built from them. + * + * @ticket 66229 + */ + public function test_registering_outside_the_action_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_fields_api_init' ); + + $this->assertSame( array(), wp_register_entity_fields( 'test-plugin', 'postType', 'page', array( $this->field( 'color' ) ), 'plugin/color' ) ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'Refusing does not fire the action.' ); + $this->assertNotContains( 'color', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ) ); + $this->assertArrayNotHasKey( 'plugin/color', wp_get_registered_entity_field_modules( 'postType', 'page' ) ); + } + + /** + * Updating only runs on the action, like registering: elsewhere it is + * refused and the registry is left untouched. + * + * @ticket 66229 + */ + public function test_updating_outside_the_action_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::update' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_fields_api_init' ); + + $this->assertSame( + array(), + wp_update_entity_fields( + 'test-plugin', + 'postType', + 'page', + array( + array( + 'id' => 'author', + 'label' => 'Writer', + ), + ) + ) + ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'Refusing does not fire the action.' ); + $fields = array_column( wp_get_registered_entity_fields( 'postType', 'page' ), null, 'id' ); + $this->assertSame( 'Author', $fields['author']['label'] ); + } + + /** + * Unregistering only runs on the action: elsewhere it is refused before + * reading the registry, so a stray call, from an `init` callback say, + * neither drops a default field nor fires the action early against + * post types still being registered. + * + * @ticket 66229 + */ + public function test_unregistering_outside_the_action_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::unregister' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_fields_api_init' ); + + $this->assertSame( array(), wp_unregister_entity_fields( 'postType', 'page', array( 'author' ) ), 'Unregistering fields by id is refused.' ); + $this->assertSame( array(), wp_unregister_entity_fields( 'postType', 'page' ), 'Unregistering every field is refused.' ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'Refusing does not fire the action.' ); + $this->assertContains( 'author', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ), 'The default field is kept.' ); + } + + /** + * A read before `init` is refused and returns nothing, without firing + * the action: the post types the defaults derive from are registered on + * `init`, and the action fires once. The next read after `init` fires it. + * + * @ticket 66229 + */ + public function test_reading_before_init_does_not_fire_the_action() { + global $wp_actions; + + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::initialize' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_fields_api_init' ); + + // did_action() counts the times a hook fired: forget `init` fired, as + // before it runs. + $did_init = $wp_actions['init']; + unset( $wp_actions['init'] ); + try { + $this->assertFalse( (bool) did_action( 'init' ) ); + $this->assertSame( array(), wp_get_registered_entity_fields( 'postType', 'page' ) ); + $this->assertSame( array(), wp_get_all_registered_entity_field_modules() ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'A read before `init` does not fire the action.' ); + } finally { + $wp_actions['init'] = $did_init; + } + + $this->assertContains( 'author', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ), 'The next read fires the action and registers the defaults.' ); + $this->assertSame( $fired + 1, did_action( 'wp_entity_fields_api_init' ) ); + } + + /** + * A read during `init` is refused too: did_action() is true from the + * first `init` callback on, while the post types are still being + * registered. The next read after `init` fires the action. + * + * @ticket 66229 + */ + public function test_reading_during_init_does_not_fire_the_action() { + global $wp_current_filter; + + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::initialize' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_fields_api_init' ); + + // doing_action() reads the stack of hooks being run: put `init` on it, + // as when an `init` callback runs. + $wp_current_filter[] = 'init'; + try { + $this->assertTrue( doing_action( 'init' ) ); + $this->assertSame( array(), wp_get_registered_entity_fields( 'postType', 'page' ) ); + $this->assertSame( array(), wp_get_all_registered_entity_field_modules() ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'A read during `init` does not fire the action.' ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->assertContains( 'author', array_column( wp_get_registered_entity_fields( 'postType', 'page' ), 'id' ), 'The next read fires the action and registers the defaults.' ); + $this->assertSame( $fired + 1, did_action( 'wp_entity_fields_api_init' ) ); + } +} diff --git a/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php b/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php new file mode 100644 index 0000000000000..88cbbec5c5d69 --- /dev/null +++ b/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php @@ -0,0 +1,211 @@ +on_entity_fields_api_init( + static function () use ( $directory, $collections, &$results ) { + foreach ( $collections as $collection ) { + $results[ $collection ] = wp_register_entity_field_collection( $directory . '/' . $collection ); + } + }, + 0 + ); + return $results; + } + + /** + * A collection lists a folder per field: the id of a field is the name + * of its folder unless its `field.php` sets one, and the fields are in + * the alphabetical order of their folders, keyed by id. + * + * @ticket 66229 + */ + public function test_a_collection_has_a_field_per_folder() { + $fields = wp_get_entity_field_collection_fields( DIR_TESTDATA . '/entity-fields/fixture' ); + + $this->assertSame( array( 'zeta', 'beta' ), array_keys( $fields ), 'The folder name is the id, unless the field sets one; the folders set the order.' ); + $this->assertSame( array( 'zeta', 'beta' ), array_column( $fields, 'id' ), 'Each field has its id.' ); + $this->assertSame( 'id', array_keys( $fields['beta'] )[0], 'The id comes first.' ); + $this->assertSame( 'Zeta', $fields['zeta']['label'] ); + } + + /** + * A missing collection has no fields. + * + * @ticket 66229 + */ + public function test_a_missing_collection_has_no_fields() { + $this->assertSame( array(), wp_get_entity_field_collection_fields( DIR_TESTDATA . '/entity-fields/missing' ) ); + } + + /** + * A collection registers its fields on its entity, after the fields + * registered on it before, with its origin and its module. Like the + * registry, it does not check that the entity exists or is exposed in + * the REST API. + * + * @ticket 66229 + */ + public function test_a_collection_places_its_fields() { + $this->register_post_types( + array( + 'test_book' => array( 'title' ), + 'test_magazine' => array( 'title' ), + 'test_hidden' => array( 'title' ), + ), + array( 'test_hidden' ) + ); + $this->register_fields( 'postType', 'test_book', array( $this->field( 'isbn' ) ) ); + $results = $this->register_fixture_collections( 'valid', array( 'book', 'magazine', 'hidden' ) ); + + $this->assertSame( + array( + 'book' => true, + 'magazine' => true, + 'hidden' => true, + ), + $results + ); + + $book = wp_get_registered_entity_fields( 'postType', 'test_book' ); + $this->assertSame( array( 'subtitle', 'isbn' ), array_column( $book, 'id' ), 'The fields registered first come first: the fixture collections run at priority 0.' ); + $this->assertSame( 'fixture', $book[0]['origin']['registeredBy'], 'The fields carry the origin of their collection.' ); + $this->assertSame( array( 'fixture/book' => array( 'subtitle' ) ), wp_get_registered_entity_field_modules( 'postType', 'test_book' ), 'Each field is registered with the module of its collection.' ); + + $this->assertSame( array( 'issue' ), array_column( wp_get_registered_entity_fields( 'postType', 'test_magazine' ), 'id' ) ); + $this->assertSame( array(), wp_get_registered_entity_field_modules( 'postType', 'test_magazine' ), 'A collection without module registers none.' ); + $this->assertSame( array( 'secret' ), array_column( wp_get_registered_entity_fields( 'postType', 'test_hidden' ), 'id' ), 'A post type not exposed in the REST API gets its fields too.' ); + } + + /** + * The files of a collection run in a scope of their own: an `index.php` + * or a `field.php` using the variables of the loader as locals, e.g. + * `$fields` to build its elements, changes nothing the loader read. + * + * @ticket 66229 + */ + public function test_the_files_of_a_collection_have_their_own_scope() { + $this->assertSame( array( 'scoped' => true ), $this->register_fixture_collections( 'valid', array( 'scoped' ) ) ); + + $fields = wp_get_registered_entity_fields( 'postType', 'test_scoped' ); + $this->assertSame( array( 'plain', 'scoped' ), array_column( $fields, 'id' ), 'The index reusing `$directory` still reads its folder; the field reusing `$fields` and `$file` keeps the field read before it and the name of its folder.' ); + $this->assertSame( array( 'draft', 'publish' ), array_column( $fields[1]['elements'], 'value' ) ); + } + + /** + * There is no precedence between collections: a field of a collection + * that redefines a field registered before it is skipped by the registry, + * like a plugin registering it twice. + * + * @ticket 66229 + */ + public function test_a_collection_redefining_a_registered_field_is_skipped() { + $this->register_post_types( array( 'test_book' => array( 'title' ) ) ); + + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register' ); + $directory = DIR_TESTDATA . '/entity-fields/collections/duplicate/book'; + $result = null; + $this->on_entity_fields_api_init( + static function () use ( $directory, &$result ) { + wp_register_entity_fields( + 'defaults', + 'postType', + 'test_book', + array( + array( + 'id' => 'authorship', + 'type' => 'text', + 'label' => 'Authorship', + ), + ) + ); + $result = wp_register_entity_field_collection( $directory ); + }, + 0 + ); + $fields = wp_get_registered_entity_fields( 'postType', 'test_book' ); + + $this->assertFalse( $result, 'A collection that loses a field returns false.' ); + $this->assertSame( array( 'authorship' ), array_column( $fields, 'id' ) ); + $this->assertSame( 'Authorship', $fields[0]['label'], 'The field registered first is kept.' ); + } + + /** + * An invalid collection is reported and skipped; the valid ones are + * registered. + * + * @ticket 66229 + */ + public function test_an_invalid_collection_is_skipped() { + $this->register_post_types( array( 'test_book' => array( 'title' ) ) ); + + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register_collection' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::register_collection' ); + $results = $this->register_fixture_collections( 'invalid', array( 'book', 'no_name', 'null_name', 'no_origin', 'missing' ) ); + $ids = array_column( wp_get_registered_entity_fields( 'postType', 'test_book' ), 'id' ); + + $this->assertSame( array( 'subtitle' ), $ids ); + $this->assertSame( + array( + 'book' => true, + 'no_name' => false, + 'null_name' => false, + 'no_origin' => false, + 'missing' => false, + ), + $results, + 'An invalid configuration returns false.' + ); + $this->assertCount( + 4, + $reported, + 'Each invalid collection is reported: no name, a null name, no origin, and a missing `index.php`.' + ); + } + + /** + * A collection is registered on the action, like fields are: elsewhere + * it is refused and the registry is left untouched. + * + * @ticket 66229 + */ + public function test_registering_a_collection_outside_the_action_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register_collection' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_fields_api_init' ); + + $this->assertFalse( wp_register_entity_field_collection( DIR_TESTDATA . '/entity-fields/collections/valid/book' ) ); + $this->assertSame( $fired, did_action( 'wp_entity_fields_api_init' ), 'Refusing does not fire the action.' ); + $this->assertSame( array(), wp_get_registered_entity_fields( 'postType', 'test_book' ) ); + } +} From 1c86d8c82bc39f509bee940eb9b4e82077ad32d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:39:46 +0200 Subject: [PATCH 2/5] REST API: Add the entity fields endpoint. Registers the `/wp/v2/entity-fields` route, served by `WP_REST_Entity_Fields_Controller`. Given an entity kind and name, it returns the fields registered for the entity with the Entity Fields API, and the script modules providing their JavaScript parts, each with the ids of the fields it applies to. Access mirrors the `/wp/v2/view-config` route: post types require their own `edit_posts` capability, taxonomies their `manage_terms` capability, and root entities `manage_options`. Post types and taxonomies that are not registered or not exposed in the REST API are not found. Synced from https://github.com/WordPress/gutenberg/pull/84078. Synced from https://github.com/WordPress/gutenberg/pull/84691. See #66229. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/rest-api.php | 4 + .../rest-entity-fields-controller.php | 642 ++++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 1 + tests/qunit/fixtures/wp-api-generated.js | 32 + 4 files changed, 679 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/rest-entity-fields-controller.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 2861e08b5868b..4ec8f119f36e0 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -440,6 +440,10 @@ function create_initial_rest_routes() { // Entity Actions. $entity_actions_controller = new WP_REST_Entity_Actions_Controller(); $entity_actions_controller->register_routes(); + + // Entity Fields. + $entity_fields_controller = new WP_REST_Entity_Fields_Controller(); + $entity_fields_controller->register_routes(); } /** diff --git a/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php new file mode 100644 index 0000000000000..400bbb5740f76 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-entity-fields-controller.php @@ -0,0 +1,642 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = $factory->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::$admin_id ); + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * The callbacks a test hooked to `wp_entity_fields_api_init`, removed on tear + * down. + * + * @var callable[] + */ + private $callbacks = array(); + + /** + * Tears down each test. + * + * Resetting the registry drops the fields a test registered; the next + * read fires `wp_entity_fields_api_init` again. + */ + public function tear_down() { + foreach ( $this->callbacks as $callback ) { + remove_action( 'wp_entity_fields_api_init', $callback ); + } + $this->callbacks = array(); + self::reset_registry(); + + parent::tear_down(); + } + + /** + * Resets the registry: drops the singleton instance, so its first read + * fires `wp_entity_fields_api_init` again. + */ + private static function reset_registry() { + $instance = new ReflectionProperty( WP_Entity_Fields_Registry::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); + } + + /** + * Registers fields on `wp_entity_fields_api_init` for the duration of the test, + * with the `test-plugin` origin: the registry is reset on tear down. + * + * Registering only runs on the action, so the registration is hooked to + * it, as a plugin does, and the action fired anew: the registry is reset + * and read, which replays the registrations made so far, in order. + * + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param array[] $fields The field definitions. + * @param string|null $module The script module id, if any. + * @return string[] The ids of the fields registered. + */ + private function register_fields( $kind, $name, $fields, $module = null ) { + $registered = array(); + $callback = static function () use ( &$registered, $kind, $name, $fields, $module ) { + $registered = wp_register_entity_fields( 'test-plugin', $kind, $name, $fields, $module ); + }; + add_action( 'wp_entity_fields_api_init', $callback ); + $this->callbacks[] = $callback; + + self::reset_registry(); + WP_Entity_Fields_Registry::get_instance()->get_all_registered(); + + return $registered; + } + + /** + * Updates registered fields on `wp_entity_fields_api_init` for the duration of the + * test, with the `test-plugin` origin, the way register_fields() registers + * them. + * + * @param string $kind The entity kind. + * @param string $name The entity name. + * @param array[] $fields The partial field definitions. + * @param string|null $module The script module id, if any. + * @return string[] The ids of the fields updated. + */ + private function update_fields( $kind, $name, $fields, $module = null ) { + $updated = array(); + $callback = static function () use ( &$updated, $kind, $name, $fields, $module ) { + $updated = wp_update_entity_fields( 'test-plugin', $kind, $name, $fields, $module ); + }; + add_action( 'wp_entity_fields_api_init', $callback ); + $this->callbacks[] = $callback; + + self::reset_registry(); + WP_Entity_Fields_Registry::get_instance()->get_all_registered(); + + return $updated; + } + + /** + * Dispatches a request to the fields route. + * + * @param string|null $kind Entity kind. + * @param string|null $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 once, and served by the controller. + * + * @covers ::register_routes + * + * @ticket 66229 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( self::ROUTE, $routes ); + $this->assertCount( 1, $routes[ self::ROUTE ], 'The route should be registered once.' ); + $this->assertInstanceOf( 'WP_REST_Entity_Fields_Controller', $routes[ self::ROUTE ][0]['callback'][0] ); + } + + /** + * Editors (with `edit_posts`) can read the fields. + * + * @covers ::get_items_permissions_check + * @covers ::get_items + * + * @ticket 66229 + */ + 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 + * + * @ticket 66229 + */ + 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 + * + * @ticket 66229 + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + + $response = $this->dispatch_request(); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + /** + * Post type fields are 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 + * + * @ticket 66229 + */ + 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() + ); + } + + /** + * A post type that is not registered is not found. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66229 + */ + public function test_get_items_unknown_post_type_is_not_found() { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_request( 'postType', 'nonexistent_post_type' ); + + $this->assertErrorResponse( 'rest_entity_fields_invalid_entity', $response, 404 ); + } + + /** + * The fields registered on a post type not exposed in the REST API are + * not served: the registry accepts them, the route does not. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66229 + */ + public function test_get_items_post_type_hidden_from_rest_is_not_found() { + register_post_type( 'test_hidden', array( 'show_in_rest' => false ) ); + wp_set_current_user( self::$admin_id ); + + try { + $registered = $this->register_fields( + 'postType', + 'test_hidden', + array( + array( + 'id' => 'secret', + 'type' => 'text', + 'label' => 'Secret', + ), + ) + ); + $response = $this->dispatch_request( 'postType', 'test_hidden' ); + } finally { + unregister_post_type( 'test_hidden' ); + } + + $this->assertSame( array( 'secret' ), $registered, 'The registry accepts the fields.' ); + $this->assertErrorResponse( 'rest_entity_fields_invalid_entity', $response, 404 ); + } + + /** + * Taxonomy fields are gated by the taxonomy's `manage_terms` capability. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66229 + */ + public function test_get_items_uses_taxonomy_capability() { + wp_set_current_user( self::$editor_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'taxonomy', 'category' )->get_status() + ); + + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'taxonomy', 'category' ), + 403 + ); + } + + /** + * A taxonomy that is not registered is not found. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66229 + */ + public function test_get_items_unknown_taxonomy_is_not_found() { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_request( 'taxonomy', 'nonexistent_taxonomy' ); + + $this->assertErrorResponse( 'rest_entity_fields_invalid_entity', $response, 404 ); + } + + /** + * Root entities require `manage_options`. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66229 + */ + public function test_get_items_root_requires_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() + ); + } + + /** + * An unknown kind falls back to `edit_posts`. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66229 + */ + 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( 'customKind', 'customName' )->get_status() + ); + + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'customKind', 'customName' ), + 403 + ); + } + + /** + * Both `kind` and `name` are required. + * + * @covers ::register_routes + * + * @ticket 66229 + */ + public function test_get_items_requires_kind_and_name() { + wp_set_current_user( self::$admin_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 entity and lists its fields and script modules. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_returns_entity_and_fields_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->assertIsArray( $data['fields'] ); + $this->assertIsArray( $data['script_modules'] ); + } + + /** + * An entity without registered fields returns empty lists, which encode as + * JSON arrays. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_returns_empty_lists_without_registered_fields() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request( 'customKind', 'customName' ); + $data = $response->get_data(); + + $this->assertSame( array(), $data['fields'] ); + $this->assertSame( array(), $data['script_modules'] ); + + $json = wp_json_encode( rest_get_server()->response_to_data( $response, false ) ); + $this->assertStringContainsString( '"fields":[]', $json ); + $this->assertStringContainsString( '"script_modules":[]', $json ); + } + + /** + * The fields registered by a plugin are exposed as registered, and each + * script module lists the fields it applies to, in registration order. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_exposes_registered_fields_and_their_script_modules() { + $color = array( + 'id' => 'color', + 'type' => 'text', + 'label' => 'Color', + 'elements' => array( + array( + 'value' => 'red', + 'label' => 'Red', + ), + ), + ); + $size = array( + 'id' => 'size', + 'type' => 'integer', + 'label' => 'Size', + ); + $this->register_fields( 'customKind', 'customName', array( $color, $size ), 'plugin/appearance' ); + $this->update_fields( 'customKind', 'customName', array( array( 'id' => 'size' ) ), 'plugin/sizes' ); + + wp_set_current_user( self::$editor_id ); + $data = $this->dispatch_request( 'customKind', 'customName' )->get_data(); + + $fields = array_column( $data['fields'], null, 'id' ); + $this->assertSame( + $color + array( + 'origin' => array( + 'registeredBy' => 'test-plugin', + 'updatedBy' => array(), + ), + ), + $fields['color'] + ); + $this->assertSame( + $size + array( + 'origin' => array( + 'registeredBy' => 'test-plugin', + 'updatedBy' => array( 'test-plugin' ), + ), + ), + $fields['size'] + ); + + $this->assertSame( + array( + array( + 'id' => 'plugin/appearance', + 'fields' => array( 'color', 'size' ), + ), + array( + 'id' => 'plugin/sizes', + 'fields' => array( 'size' ), + ), + ), + $data['script_modules'] + ); + } + + /** + * Fields registered without a script module have none listed. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_get_items_lists_no_script_module_for_plain_fields() { + $this->register_fields( + 'customKind', + 'customName', + array( + array( + 'id' => 'plain', + 'type' => 'text', + 'label' => 'Plain', + ), + ) + ); + + wp_set_current_user( self::$editor_id ); + $data = $this->dispatch_request( 'customKind', 'customName' )->get_data(); + + $this->assertSame( array( 'plain' ), array_column( $data['fields'], 'id' ) ); + $this->assertSame( array(), $data['script_modules'] ); + } + + /** + * Empty object-typed field properties serialize as JSON objects rather + * than arrays. + * + * @covers ::get_items + * + * @ticket 66229 + */ + public function test_empty_object_properties_serialize_as_json_objects() { + $this->register_fields( + 'customKind', + 'customName', + array( + array( + 'id' => 'plain', + 'type' => 'text', + 'filterBy' => array(), + 'isValid' => array(), + 'format' => array(), + 'Edit' => array(), + 'elements' => array( array() ), + ), + array( + 'id' => 'list', + 'type' => 'text', + 'elements' => array(), + ), + ) + ); + + wp_set_current_user( self::$editor_id ); + $response = $this->dispatch_request( 'customKind', 'customName' ); + $json = wp_json_encode( rest_get_server()->response_to_data( $response, false ) ); + + $this->assertStringContainsString( '"filterBy":{}', $json ); + $this->assertStringContainsString( '"isValid":{}', $json ); + $this->assertStringContainsString( '"format":{}', $json ); + $this->assertStringContainsString( '"Edit":{}', $json ); + $this->assertStringContainsString( '"elements":[{}]', $json, 'Empty items typed as objects encode as objects.' ); + $this->assertStringContainsString( '"elements":[]', $json, 'Empty lists stay lists.' ); + } + + /** + * The item schema describes the response. + * + * @covers ::get_item_schema + * @covers ::get_field_schema + * + * @ticket 66229 + */ + public function test_get_item_schema() { + $controller = new WP_REST_Entity_Fields_Controller(); + $schema = $controller->get_item_schema(); + + $this->assertSame( array( 'kind', 'name', 'fields', 'script_modules' ), array_keys( $schema['properties'] ) ); + + $field = $schema['properties']['fields']['items']; + $this->assertSame( 'object', $field['type'] ); + $this->assertTrue( $field['additionalProperties'], 'A plugin can register properties of its own.' ); + foreach ( array( 'id', 'origin', 'type', 'label', 'Edit', 'isValid', 'elements', 'filterBy', 'readOnly', 'format' ) as $property ) { + $this->assertArrayHasKey( $property, $field['properties'], "The `$property` field property should be described." ); + } + $this->assertContains( 'text', $field['properties']['type']['enum'], 'The schema lists the field types DataViews provides.' ); + + $module = $schema['properties']['script_modules']['items']; + $this->assertSame( array( 'id', 'fields' ), array_keys( $module['properties'] ) ); + } + + /** + * The response of the route validates against its schema. + * + * @covers ::get_items + * @covers ::get_item_schema + * + * @ticket 66229 + */ + public function test_get_items_response_matches_the_schema() { + $this->register_fields( + 'postType', + 'page', + array( + array( + 'id' => 'color', + 'type' => 'text', + 'label' => 'Color', + 'filterBy' => array( + 'operators' => array( 'isAny' ), + ), + ), + ), + 'plugin/appearance' + ); + + wp_set_current_user( self::$editor_id ); + $response = $this->dispatch_request( 'postType', 'page' ); + $schema = ( new WP_REST_Entity_Fields_Controller() )->get_item_schema(); + + $this->assertTrue( rest_validate_value_from_schema( $response->get_data(), $schema ) ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 211d0fcd4afeb..7ae0cb0350deb 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -207,6 +207,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/wp/v2/view-config', '/wp/v2/entity-actions', + '/wp/v2/entity-fields', '/wp-abilities/v1', '/wp-abilities/v1/categories', '/wp-abilities/v1/categories/(?P[a-z0-9]+(?:-[a-z0-9]+)*)', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 3caf6ba066213..d3ba47bef2fb6 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -13119,6 +13119,38 @@ mockedApiResponse.Schema = { } ] } + }, + "/wp/v2/entity-fields": { + "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/entity-fields" + } + ] + } } }, "image_sizes": { From 4316614793b819f2efef25785f08a536cfd71ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:40:38 +0200 Subject: [PATCH 3/5] Entity Fields: Preload the fields of the post types the Site Editor lists. The Site Editor reads the fields of the post types it lists from the `/wp/v2/entity-fields` route before it can render them. The fields of the edited post type, if any, and of the post types of the screen the Site Editor opens on, from its `p` query arg, are now added to the preloaded paths. Synced from https://github.com/WordPress/gutenberg/pull/84700. See #66229. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/default-filters.php | 1 + src/wp-includes/entity-fields.php | 103 +++++++++++ .../entity-fields/wpPreloadEntityFields.php | 163 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 tests/phpunit/tests/entity-fields/wpPreloadEntityFields.php diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index d378939409573..16afab80fcd58 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -858,5 +858,6 @@ // Entity Fields API. add_action( 'admin_footer', '_wp_add_entity_field_modules_to_editor_script', 10, 0 ); +add_filter( 'block_editor_rest_api_preload_paths', '_wp_preload_entity_fields', 10, 2 ); unset( $filter, $action, $post_type ); diff --git a/src/wp-includes/entity-fields.php b/src/wp-includes/entity-fields.php index 67578ac5020dd..fff833b6788b8 100644 --- a/src/wp-includes/entity-fields.php +++ b/src/wp-includes/entity-fields.php @@ -252,3 +252,106 @@ function wp_get_entity_field_collection_fields( $directory ) { function wp_register_entity_field_collection( $directory ) { return WP_Entity_Fields_Registry::get_instance()->register_collection( $directory ); } + +/** + * Returns the paths that serve the fields of the given post types, skipping + * the post types that are not registered. + * + * A path must match the request of the `getFieldsConfig` core data resolver, + * see packages/core-data/src/resolvers.js in Gutenberg. + * + * @since 7.2.0 + * @access private + * + * @param string[] $post_types The post types. + * @return string[] One path of the `/wp/v2/entity-fields` route per post type, + * in the order the post types are given, without duplicates. + */ +function _wp_get_post_type_fields_preload_paths( $post_types ) { + $paths = array(); + foreach ( array_unique( $post_types ) as $post_type ) { + if ( ! post_type_exists( $post_type ) ) { + continue; + } + $paths[] = add_query_arg( + array( + 'kind' => 'postType', + 'name' => $post_type, + ), + '/wp/v2/entity-fields' + ); + } + + return $paths; +} + +/** + * Returns the post types whose fields the screen of the site editor being + * loaded lists, from its `p` query arg. + * + * The screens are the routes in + * packages/edit-site/src/components/site-editor-routes in Gutenberg: the + * lists and the editors of pages, templates, patterns, and template parts. + * The patterns screen lists both patterns and template parts. + * + * @since 7.2.0 + * @access private + * + * @return string[] The post types, empty for any other screen. + */ +function _wp_get_site_editor_screen_post_types() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks what to preload. + $path = isset( $_GET['p'] ) && is_string( $_GET['p'] ) ? sanitize_text_field( wp_unslash( $_GET['p'] ) ) : ''; + $segments = explode( '/', trim( $path, '/' ) ); + + switch ( $segments[0] ) { + case 'page': + return array( 'page' ); + case 'template': + case 'wp_template': + return array( 'wp_template' ); + case 'pattern': + return array( 'wp_block', 'wp_template_part' ); + case 'wp_block': + return array( 'wp_block' ); + case 'wp_template_part': + return array( 'wp_template_part' ); + } + return array(); +} + +/** + * Preloads the fields of the post types the site editor lists. + * + * The site editor reads them from the `/wp/v2/entity-fields` route before it + * can list the post fields, see `useFields` in + * packages/editor/src/dataviews/fields-loader/index.ts in Gutenberg: those of + * the edited post, if any, and of the post types of the screen it opens on. + * + * The post editor is left untouched: it only reads the fields in the + * DataForm-based inspector, an experiment of the Gutenberg plugin which + * preloads them on its own. + * + * @since 7.2.0 + * @access private + * + * @param array $paths REST API paths to preload. + * @param WP_Block_Editor_Context $context Block editor context. + * @return array Filtered preload paths. + */ +function _wp_preload_entity_fields( $paths, $context ) { + if ( 'core/edit-site' !== $context->name ) { + return $paths; + } + + $post_types = array(); + if ( isset( $context->post ) ) { + $post_types[] = $context->post->post_type; + } + $post_types = array_merge( $post_types, _wp_get_site_editor_screen_post_types() ); + + return array_merge( + $paths, + _wp_get_post_type_fields_preload_paths( $post_types ) + ); +} diff --git a/tests/phpunit/tests/entity-fields/wpPreloadEntityFields.php b/tests/phpunit/tests/entity-fields/wpPreloadEntityFields.php new file mode 100644 index 0000000000000..064fbbf063566 --- /dev/null +++ b/tests/phpunit/tests/entity-fields/wpPreloadEntityFields.php @@ -0,0 +1,163 @@ +assertSame( 10, has_filter( 'block_editor_rest_api_preload_paths', '_wp_preload_entity_fields' ) ); + } + + /** + * The post editor does not preload the fields, whatever its `p` query arg. + * + * @ticket 66229 + */ + public function test_the_post_editor_does_not_preload_the_fields() { + $_GET['p'] = '/page'; + $post = self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ); + $context = new WP_Block_Editor_Context( + array( + 'name' => 'core/edit-post', + 'post' => $post, + ) + ); + + $this->assertSame( array(), _wp_preload_entity_fields( array(), $context ) ); + } + + /** + * The preloaded path serves the fields of the post type. + * + * @ticket 66229 + */ + public function test_the_preloaded_fields_path_serves_the_fields() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + $_GET['p'] = '/page'; + $context = new WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) ); + $paths = _wp_preload_entity_fields( array(), $context ); + + $preloaded = array_reduce( $paths, 'rest_preload_api_request', array() ); + + $this->assertSame( 'page', $preloaded[ $paths[0] ]['body']['name'] ); + $this->assertContains( 'author', wp_list_pluck( $preloaded[ $paths[0] ]['body']['fields'], 'id' ) ); + } + + /** + * The site editor preloads the fields of the post types its screen lists, + * from the `p` query arg. + * + * @ticket 66229 + * + * @dataProvider data_site_editor_screens + * + * @param string $path The `p` query arg. + * @param string[] $post_types The post types whose fields are preloaded. + */ + public function test_the_site_editor_preloads_the_fields_of_its_screen( $path, $post_types ) { + $_GET['p'] = $path; + $context = new WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) ); + + $paths = _wp_preload_entity_fields( array(), $context ); + unset( $_GET['p'] ); + + $expected = array(); + foreach ( $post_types as $post_type ) { + $expected[] = '/wp/v2/entity-fields?kind=postType&name=' . $post_type; + } + $this->assertSame( $expected, $paths ); + } + + /** + * Data provider for test_the_site_editor_preloads_the_fields_of_its_screen(). + * + * @return array[] + */ + public function data_site_editor_screens() { + return array( + 'pages' => array( '/page', array( 'page' ) ), + 'page' => array( '/page/12', array( 'page' ) ), + 'templates' => array( '/template', array( 'wp_template' ) ), + 'template' => array( '/wp_template/emptytheme//index', array( 'wp_template' ) ), + 'patterns' => array( '/pattern', array( 'wp_block', 'wp_template_part' ) ), + 'pattern' => array( '/wp_block/12', array( 'wp_block' ) ), + 'template part' => array( '/wp_template_part/emptytheme//header', array( 'wp_template_part' ) ), + 'root' => array( '/', array() ), + 'styles' => array( '/styles', array() ), + 'navigation' => array( '/navigation', array() ), + ); + } + + /** + * The site editor preloads the fields of the edited post, once, next to + * those of its screen. + * + * @ticket 66229 + */ + public function test_the_site_editor_preloads_the_fields_of_the_edited_post() { + $post = self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ); + $_GET['p'] = '/page'; + $context = new WP_Block_Editor_Context( + array( + 'name' => 'core/edit-site', + 'post' => $post, + ) + ); + + $paths = _wp_preload_entity_fields( array(), $context ); + unset( $_GET['p'] ); + + $this->assertSame( array( '/wp/v2/entity-fields?kind=postType&name=page' ), $paths ); + } + + /** + * Post types that are not registered are skipped. + * + * @ticket 66229 + */ + public function test_unregistered_post_types_are_not_preloaded() { + $this->assertSame( + array( '/wp/v2/entity-fields?kind=postType&name=page' ), + _wp_get_post_type_fields_preload_paths( array( 'page', 'not_a_post_type', 'page' ) ) + ); + } + + /** + * Other editors, a post editor context without a post, and a site editor + * screen that lists no post fields are left untouched. + * + * @ticket 66229 + */ + public function test_other_editors_do_not_preload_the_fields() { + $site_editor = new WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) ); + $no_post = new WP_Block_Editor_Context( array( 'name' => 'core/edit-post' ) ); + $widgets = new WP_Block_Editor_Context( array( 'name' => 'core/edit-widgets' ) ); + + $this->assertSame( array(), _wp_preload_entity_fields( array(), $site_editor ) ); + $this->assertSame( array(), _wp_preload_entity_fields( array(), $no_post ) ); + $this->assertSame( array(), _wp_preload_entity_fields( array(), $widgets ) ); + } +} From c478159fe1a33854ae8d506e4f0a9f68b34cbc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:25:31 +0200 Subject: [PATCH 4/5] Entity Fields: Validate the ids of collection fields before using them. A `field.php` that returned a non-string `id`, such as an array, caused a fatal error when the id was used as a key in get_collection_fields(). Other non-string ids were silently coerced, and a folder setting the id of an earlier one silently replaced it. Such fields are now reported with _doing_it_wrong() and skipped, along with a `field.php` that does not return an array. The rest of the collection is read, and register_collection() returns false when a field was skipped. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-entity-fields-registry.php | 134 ++++++++++++++---- .../invalid_fields/book/array_id/field.php | 12 ++ .../invalid_fields/book/empty_id/field.php | 12 ++ .../collections/invalid_fields/book/index.php | 12 ++ .../invalid_fields/book/int_id/field.php | 12 ++ .../invalid_fields/book/not_array/field.php | 8 ++ .../invalid_fields/book/null_id/field.php | 12 ++ .../invalid_fields/book/valid/field.php | 11 ++ .../invalid_fields/book/valid_again/field.php | 12 ++ .../wpRegisterEntityFieldCollection.php | 40 ++++++ 10 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/array_id/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/empty_id/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/index.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/int_id/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/not_array/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/null_id/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid/field.php create mode 100644 tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid_again/field.php diff --git a/src/wp-includes/class-wp-entity-fields-registry.php b/src/wp-includes/class-wp-entity-fields-registry.php index 3c0227931148d..11ed6011080df 100644 --- a/src/wp-includes/class-wp-entity-fields-registry.php +++ b/src/wp-includes/class-wp-entity-fields-registry.php @@ -383,14 +383,17 @@ public function unregister( $kind, $name, $ids = null ) { * register(). * * Like register(), it only runs on the `wp_entity_fields_api_init` action. An - * invalid configuration is reported with _doing_it_wrong() and skipped. + * invalid configuration is reported with _doing_it_wrong() and skipped. So + * is an invalid `field.php`, see get_collection_fields(): the rest of the + * fields of the collection are registered. * * @since 7.2.0 * * @param string $directory The directory of the collection. * @return bool Whether the collection is valid and all of its fields are * registered. True as well when it has no fields; false when - * called outside the `wp_entity_fields_api_init` action. + * a `field.php` is skipped, or when called outside the + * `wp_entity_fields_api_init` action. */ public function register_collection( $directory ) { if ( ! $this->doing_entity_fields_api_init( __METHOD__ ) ) { @@ -403,11 +406,11 @@ public function register_collection( $directory ) { } if ( empty( $collection['fields'] ) ) { - return true; + return ! $collection['skipped']; } $registered = $this->register( $collection['origin'], $collection['kind'], $collection['name'], $collection['fields'], $collection['module'] ); - return count( $registered ) === count( $collection['fields'] ); + return ! $collection['skipped'] && count( $registered ) === count( $collection['fields'] ); } /** @@ -423,27 +426,13 @@ public function register_collection( $directory ) { * the alphabetical order of their folders. The * id of a field is the name of its folder, * unless its `field.php` sets an `id`, and it - * comes first in the field. + * comes first in the field. A `field.php` + * that does not return an array with a + * non-empty string id, or whose id an earlier + * folder took, is reported and skipped. */ public function get_collection_fields( $directory ) { - $files = glob( $directory . '/*/field.php' ); - if ( empty( $files ) ) { - return array(); - } - sort( $files, SORT_STRING ); - - $fields = array(); - foreach ( $files as $file ) { - $field = self::require_file( $file ); - if ( ! is_array( $field ) ) { - continue; - } - // The id comes first, like in the fields registered directly. - $field = array_merge( array( 'id' => basename( dirname( $file ) ) ), $field ); - $fields[ $field['id'] ] = $field; - } - - return $fields; + return $this->read_collection_fields( __METHOD__, $directory )['fields']; } /** @@ -808,7 +797,9 @@ protected function skip_invalid_ids( $method, $kind, $name, $ids ) { * @param string $method The calling method, for the notice. * @param string $directory The directory of the collection. * @return array|null The configuration of the collection, with the `module` - * key set and its `fields` as a list, or null when the + * key set, its valid `fields` as a list, and `skipped` + * telling whether a `field.php` was skipped, see + * read_collection_fields(); or null when the * configuration is invalid. */ protected function get_collection( $method, $directory ) { @@ -839,15 +830,106 @@ protected function get_collection( $method, $directory ) { return null; } + $read = $this->read_collection_fields( $method, $directory ); + return array_merge( $config, array( - 'module' => $config['module'] ?? null, - 'fields' => array_values( $this->get_collection_fields( $directory ) ), + 'module' => $config['module'] ?? null, + 'fields' => array_values( $read['fields'] ), + 'skipped' => $read['skipped'], ) ); } + /** + * Reads the fields of a field collection, see get_collection_fields(). + * + * The id of a field is used as a key, so it is checked before: a + * `field.php` that does not return an array with a non-empty string id + * is reported with _doing_it_wrong() and skipped, and so is one whose + * id an earlier folder of the collection took. The rest of the fields + * are read. + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @param string $directory The directory of the collection. + * @return array { + * The fields of the collection. + * + * @type array $fields The valid fields, keyed by id, + * in the alphabetical order of + * their folders. + * @type bool $skipped Whether a `field.php` was skipped. + * } + */ + protected function read_collection_fields( $method, $directory ) { + $files = glob( $directory . '/*/field.php' ); + if ( empty( $files ) ) { + return array( + 'fields' => array(), + 'skipped' => false, + ); + } + sort( $files, SORT_STRING ); + + $fields = array(); + $invalid = array(); + $duplicated = array(); + foreach ( $files as $file ) { + $folder = basename( dirname( $file ) ); + $field = self::require_file( $file ); + if ( ! is_array( $field ) ) { + $invalid[] = $folder; + continue; + } + + // The id comes first, like in the fields registered directly. + $field = array_merge( array( 'id' => $folder ), $field ); + $id = $field['id']; + if ( ! is_string( $id ) || '' === $id ) { + $invalid[] = $folder; + } elseif ( isset( $fields[ $id ] ) ) { + $duplicated[] = $folder; + } else { + $fields[ $id ] = $field; + } + } + + $collection = basename( $directory ); + if ( $invalid ) { + _doing_it_wrong( + $method, + sprintf( + /* translators: 1: The name of a field collection. 2: Comma-separated list of folder names. */ + __( 'The `field.php` of a field must return an array, with a non-empty string `id` if it sets one. These fields of the field collection "%1$s" are skipped: %2$s.' ), + $collection, + implode( ', ', $invalid ) + ), + '7.2.0' + ); + } + + if ( $duplicated ) { + _doing_it_wrong( + $method, + sprintf( + /* translators: 1: The name of a field collection. 2: Comma-separated list of folder names. */ + __( 'A field can only be defined once in a field collection. These fields of the field collection "%1$s" are skipped because an earlier folder defines their id: %2$s.' ), + $collection, + implode( ', ', $duplicated ) + ), + '7.2.0' + ); + } + + return array( + 'fields' => $fields, + 'skipped' => $invalid || $duplicated, + ); + } + /** * Requires a file without sharing the variables of the caller: this is * about variable scope. diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/array_id/field.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/array_id/field.php new file mode 100644 index 0000000000000..9d8ba3a624ce1 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/array_id/field.php @@ -0,0 +1,12 @@ + array( 'array_id' ), + 'type' => 'text', + 'label' => 'Array id', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/empty_id/field.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/empty_id/field.php new file mode 100644 index 0000000000000..94f4d28d103ff --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/empty_id/field.php @@ -0,0 +1,12 @@ + '', + 'type' => 'text', + 'label' => 'Empty id', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/index.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/index.php new file mode 100644 index 0000000000000..96fc6814ef4a0 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/index.php @@ -0,0 +1,12 @@ + 'fixture', + 'kind' => 'postType', + 'name' => 'test_book', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/int_id/field.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/int_id/field.php new file mode 100644 index 0000000000000..71c16893083fe --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/int_id/field.php @@ -0,0 +1,12 @@ + 42, + 'type' => 'text', + 'label' => 'Int id', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/not_array/field.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/not_array/field.php new file mode 100644 index 0000000000000..fe860658edacb --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/not_array/field.php @@ -0,0 +1,8 @@ + null, + 'type' => 'text', + 'label' => 'Null id', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid/field.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid/field.php new file mode 100644 index 0000000000000..c2ab3c15ac95a --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid/field.php @@ -0,0 +1,11 @@ + 'text', + 'label' => 'Valid', +); diff --git a/tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid_again/field.php b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid_again/field.php new file mode 100644 index 0000000000000..c7af2556a5759 --- /dev/null +++ b/tests/phpunit/data/entity-fields/collections/invalid_fields/book/valid_again/field.php @@ -0,0 +1,12 @@ + 'valid', + 'type' => 'text', + 'label' => 'Valid again', +); diff --git a/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php b/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php index 88cbbec5c5d69..cc7bc78f6357f 100644 --- a/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php +++ b/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php @@ -193,6 +193,46 @@ public function test_an_invalid_collection_is_skipped() { ); } + /** + * A `field.php` that does not return an array with a non-empty string + * id, or whose id an earlier folder took, is reported and skipped + * before its id is used as a key: an array id does not cause a fatal + * error, and a later folder does not replace an earlier one. + * + * @ticket 66229 + */ + public function test_invalid_collection_fields_are_skipped() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::get_collection_fields' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::get_collection_fields' ); + $fields = wp_get_entity_field_collection_fields( DIR_TESTDATA . '/entity-fields/collections/invalid_fields/book' ); + + $this->assertSame( array( 'valid' ), array_keys( $fields ), 'Only the valid field is read.' ); + $this->assertSame( 'Valid', $fields['valid']['label'], 'A later folder with the same id does not replace the earlier one.' ); + $this->assertCount( 2, $reported, 'The invalid fields and the duplicated ones are reported once each.' ); + $this->assertStringContainsString( 'array_id, empty_id, int_id, not_array, null_id', $reported[0], 'The invalid fields are named by folder.' ); + $this->assertStringContainsString( 'valid_again', $reported[1], 'The duplicated fields are named by folder.' ); + } + + /** + * A collection with invalid fields registers its valid ones, and returns + * false since not all of its fields are registered. + * + * @ticket 66229 + */ + public function test_a_collection_with_invalid_fields_registers_the_valid_ones() { + $this->register_post_types( array( 'test_book' => array( 'title' ) ) ); + + $this->setExpectedIncorrectUsage( 'WP_Entity_Fields_Registry::register_collection' ); + $reported = &$this->record_notices( 'WP_Entity_Fields_Registry::register_collection' ); + $results = $this->register_fixture_collections( 'invalid_fields', array( 'book' ) ); + $fields = wp_get_registered_entity_fields( 'postType', 'test_book' ); + + $this->assertSame( array( 'book' => false ), $results, 'A collection that skips a field returns false.' ); + $this->assertSame( array( 'valid' ), array_column( $fields, 'id' ) ); + $this->assertSame( 'Valid', $fields[0]['label'] ); + $this->assertCount( 2, $reported, 'The notices name the method that registers the collection.' ); + } + /** * A collection is registered on the action, like fields are: elsewhere * it is refused and the registry is left untouched. From ff8698d63081662374d1c256fd56c32946cd1c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:30:03 +0200 Subject: [PATCH 5/5] Entity Fields: Keep code out of translatable strings. Pass hook names, file names, keys, and method names as placeholders, wrapped in ``, instead of keeping them inside the strings to translate, as core does elsewhere. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-entity-fields-registry.php | 72 ++++++++++++++----- ...class-wp-rest-entity-fields-controller.php | 6 +- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/class-wp-entity-fields-registry.php b/src/wp-includes/class-wp-entity-fields-registry.php index 11ed6011080df..e25f2e026a0fe 100644 --- a/src/wp-includes/class-wp-entity-fields-registry.php +++ b/src/wp-includes/class-wp-entity-fields-registry.php @@ -165,11 +165,12 @@ public function register( $origin, $kind, $name, $fields, $script_module = null _doing_it_wrong( __METHOD__, sprintf( - /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. */ - __( 'A field can only be registered once. These fields of %1$s "%2$s" are skipped because they are already registered: %3$s. Use update() to change a registered field, or unregister it first to replace it.' ), + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. 4: update() */ + __( 'A field can only be registered once. These fields of %1$s "%2$s" are skipped because they are already registered: %3$s. Use %4$s to change a registered field, or unregister it first to replace it.' ), $kind, $name, - implode( ', ', $registered_ids ) + implode( ', ', $registered_ids ), + 'update()' ), '7.2.0' ); @@ -265,11 +266,12 @@ public function update( $origin, $kind, $name, $fields, $script_module = null ) _doing_it_wrong( __METHOD__, sprintf( - /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. */ - __( 'Only registered fields can be updated. These fields of %1$s "%2$s" are skipped because they are not registered: %3$s. Use register() to add a field.' ), + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. 4: register() */ + __( 'Only registered fields can be updated. These fields of %1$s "%2$s" are skipped because they are not registered: %3$s. Use %4$s to add a field.' ), $kind, $name, - implode( ', ', $unregistered_ids ) + implode( ', ', $unregistered_ids ), + 'register()' ), '7.2.0' ); @@ -528,7 +530,12 @@ protected function initialize() { if ( ! did_action( 'init' ) || doing_action( 'init' ) ) { _doing_it_wrong( __METHOD__, - __( 'The registered fields cannot be read until the `init` action has completed: the post types and supports they derive from are registered on `init`. Read them once `init` has completed, or hook `wp_entity_fields_api_init`.' ), + sprintf( + /* translators: 1: init, 2: wp_entity_fields_api_init */ + __( 'The registered fields cannot be read until the %1$s action has completed: the post types and supports they derive from are registered on %1$s. Read them once %1$s has completed, or hook %2$s.' ), + 'init', + 'wp_entity_fields_api_init' + ), '7.2.0' ); return; @@ -577,7 +584,11 @@ protected function doing_entity_fields_api_init( $method ) { _doing_it_wrong( $method, - __( 'Fields can only be registered, updated, and unregistered on the `wp_entity_fields_api_init` action.' ), + sprintf( + /* translators: %s: wp_entity_fields_api_init */ + __( 'Fields can only be registered, updated, and unregistered on the %s action.' ), + 'wp_entity_fields_api_init' + ), '7.2.0' ); return false; @@ -731,11 +742,12 @@ protected function skip_invalid_definitions( $method, $kind, $name, $fields ) { _doing_it_wrong( $method, sprintf( - /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of positions, e.g. #2, #4. */ - __( 'Every field definition must be an array with a non-empty string `id`. These definitions of %1$s "%2$s" are skipped: %3$s. The rest of the fields of the call are stored.' ), + /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of positions, e.g. #2, #4. 4: id */ + __( 'Every field definition must be an array with a non-empty string %4$s. These definitions of %1$s "%2$s" are skipped: %3$s. The rest of the fields of the call are stored.' ), $kind, $name, - implode( ', ', $invalid ) + implode( ', ', $invalid ), + 'id' ), '7.2.0' ); @@ -809,15 +821,35 @@ protected function get_collection( $method, $directory ) { $error = null; if ( ! is_array( $config ) ) { - $error = __( 'The `index.php` of a field collection must return an array.' ); + $error = sprintf( + /* translators: %s: index.php */ + __( 'The %s of a field collection must return an array.' ), + 'index.php' + ); } elseif ( ! isset( $config['origin'] ) || ! is_string( $config['origin'] ) || '' === $config['origin'] ) { - $error = __( 'The `origin` of a field collection must be a non-empty string.' ); + $error = sprintf( + /* translators: %s: origin */ + __( 'The %s of a field collection must be a non-empty string.' ), + 'origin' + ); } elseif ( ! isset( $config['kind'] ) || ! is_string( $config['kind'] ) || '' === $config['kind'] ) { - $error = __( 'The `kind` of a field collection must be a non-empty string.' ); + $error = sprintf( + /* translators: %s: kind */ + __( 'The %s of a field collection must be a non-empty string.' ), + 'kind' + ); } elseif ( ! isset( $config['name'] ) || ! is_string( $config['name'] ) || '' === $config['name'] ) { - $error = __( 'The `name` of a field collection must be a non-empty string.' ); + $error = sprintf( + /* translators: %s: name */ + __( 'The %s of a field collection must be a non-empty string.' ), + 'name' + ); } elseif ( isset( $config['module'] ) && ( ! is_string( $config['module'] ) || '' === $config['module'] ) ) { - $error = __( 'The `module` of a field collection must be the id of a script module.' ); + $error = sprintf( + /* translators: %s: module */ + __( 'The %s of a field collection must be the id of a script module.' ), + 'module' + ); } if ( null !== $error ) { @@ -902,10 +934,12 @@ protected function read_collection_fields( $method, $directory ) { _doing_it_wrong( $method, sprintf( - /* translators: 1: The name of a field collection. 2: Comma-separated list of folder names. */ - __( 'The `field.php` of a field must return an array, with a non-empty string `id` if it sets one. These fields of the field collection "%1$s" are skipped: %2$s.' ), + /* translators: 1: The name of a field collection. 2: Comma-separated list of folder names. 3: field.php 4: id */ + __( 'The %3$s of a field must return an array, with a non-empty string %4$s if it sets one. These fields of the field collection "%1$s" are skipped: %2$s.' ), $collection, - implode( ', ', $invalid ) + implode( ', ', $invalid ), + 'field.php', + 'id' ), '7.2.0' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php index 6f39b906a1182..dae68fff62106 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php @@ -279,7 +279,11 @@ protected function get_field_schema() { 'type' => 'string', ), 'origin' => array( - 'description' => __( 'Who registered and updated the field: `core`, or the slug of a plugin or theme.' ), + 'description' => sprintf( + /* translators: %s: core */ + __( 'Who registered and updated the field: %s, or the slug of a plugin or theme.' ), + 'core' + ), 'type' => 'object', 'properties' => array( 'registeredBy' => array(