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..e25f2e026a0fe --- /dev/null +++ b/src/wp-includes/class-wp-entity-fields-registry.php @@ -0,0 +1,1029 @@ + 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. 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 ), + 'update()' + ), + '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. 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 ), + 'register()' + ), + '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. 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 + * 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__ ) ) { + return false; + } + + $collection = $this->get_collection( __METHOD__, $directory ); + if ( null === $collection ) { + return false; + } + + if ( empty( $collection['fields'] ) ) { + return ! $collection['skipped']; + } + $registered = $this->register( $collection['origin'], $collection['kind'], $collection['name'], $collection['fields'], $collection['module'] ); + + return ! $collection['skipped'] && 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. 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 ) { + return $this->read_collection_fields( __METHOD__, $directory )['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__, + 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; + } + + // 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, + 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; + } + + /** + * 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. 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 ), + 'id' + ), + '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, 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 ) { + $slug = basename( $directory ); + $file = $directory . '/index.php'; + $config = is_file( $file ) ? self::require_file( $file ) : null; + $error = null; + + if ( ! is_array( $config ) ) { + $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 = 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 = 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 = 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 = sprintf( + /* translators: %s: module */ + __( 'The %s of a field collection must be the id of a script module.' ), + '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; + } + + $read = $this->read_collection_fields( $method, $directory ); + + return array_merge( + $config, + array( + '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. 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 ), + 'field.php', + 'id' + ), + '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. + * + * @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..16afab80fcd58 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -856,4 +856,8 @@ // 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 ); +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 new file mode 100644 index 0000000000000..fff833b6788b8 --- /dev/null +++ b/src/wp-includes/entity-fields.php @@ -0,0 +1,357 @@ +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 ); +} + +/** + * 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/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/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..dae68fff62106 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php @@ -0,0 +1,402 @@ +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' => 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( + '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/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/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/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 ) ); + } +} diff --git a/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php b/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php new file mode 100644 index 0000000000000..cc7bc78f6357f --- /dev/null +++ b/tests/phpunit/tests/entity-fields/wpRegisterEntityFieldCollection.php @@ -0,0 +1,251 @@ +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 `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. + * + * @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' ) ); + } +} 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": {