From 934e73aa1834db155027d113c37f66f0e5b0dab1 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 14 Oct 2026 14:36:42 +0100 Subject: [PATCH 1/3] Editor: Add the entity actions API. Adds a registry for the actions DataViews shows for an entity, so they can be registered on the server. An action definition is the serializable part of a DataViews action. Its JavaScript parts come from a script module registered along with the actions. Actions are registered with `wp_register_entity_actions()` and unregistered with `wp_unregister_entity_actions()`, only on the `wp_entity_actions_api_init` action, which fires the first time the registry is read after `init`. The `GET /wp/v2/entity-actions` route returns the actions of an entity and their script modules, and the script modules are added to the import map of the pages that load the editor script, as dynamic module dependencies of `wp-editor`. Backports https://github.com/WordPress/gutenberg/pull/84650. --- .../class-wp-entity-actions-registry.php | 741 ++++++++++++++ src/wp-includes/default-filters.php | 3 + src/wp-includes/entity-actions.php | 188 ++++ src/wp-includes/rest-api.php | 4 + ...lass-wp-rest-entity-actions-controller.php | 354 +++++++ src/wp-settings.php | 3 + tests/phpunit/tests/entity-actions.php | 925 ++++++++++++++++++ .../rest-entity-actions-controller.php | 557 +++++++++++ .../tests/rest-api/rest-schema-setup.php | 1 + tests/qunit/fixtures/wp-api-generated.js | 34 + 10 files changed, 2810 insertions(+) create mode 100644 src/wp-includes/class-wp-entity-actions-registry.php create mode 100644 src/wp-includes/entity-actions.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php create mode 100644 tests/phpunit/tests/entity-actions.php create mode 100644 tests/phpunit/tests/rest-api/rest-entity-actions-controller.php diff --git a/src/wp-includes/class-wp-entity-actions-registry.php b/src/wp-includes/class-wp-entity-actions-registry.php new file mode 100644 index 0000000000000..ba523d3a6c760 --- /dev/null +++ b/src/wp-includes/class-wp-entity-actions-registry.php @@ -0,0 +1,741 @@ + + */ + const SUPPORTED_VALUES = array( + 'context' => array( 'list', 'single' ), + 'modalSize' => array( 'small', 'medium', 'large', 'fill' ), + ); + + /** + * Registered actions, as kind => name => array of action 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 $actions = array(); + + /** + * Registered script modules, as kind => name => array of module id => + * list of the ids of the actions the module applies to, in registration + * order. + * + * @since 7.2.0 + * + * @var array>> + */ + protected $action_modules = array(); + + /** + * Whether the `wp_entity_actions_api_init` action has fired since the registry + * was created. + * + * @since 7.2.0 + * + * @var bool + */ + protected $initialized = false; + + /** + * Whether the registry is firing the `wp_entity_actions_api_init` action, the + * only time register() and unregister() run. + * + * @since 7.2.0 + * + * @var bool + */ + protected $initializing = false; + + /** + * The singleton instance. + * + * @since 7.2.0 + * + * @var WP_Entity_Actions_Registry|null + */ + protected static $instance = null; + + /** + * Returns the singleton instance. + * + * @since 7.2.0 + * + * @return WP_Entity_Actions_Registry The registry. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Registers actions for an entity. + * + * The actions must be new: an action with the id of a registered action + * 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 actions + * of the call are registered: an action 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 replace + * a registered action, unregister it first. The script module, if any, + * applies to every action the call registers. + * + * A value DataViews does not support is reported, and a property only a + * script module can provide is reported and removed, see + * report_invalid_values(). + * + * The origin is stored as the `origin` property of each action, as + * `registeredBy`, replacing any `origin` the definition sets. + * + * @since 7.2.0 + * + * @param string $origin Who registers the actions: `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[] $actions The list of action definitions, each with an `id`. + * @param string|null $script_module The id of the script module providing + * the JavaScript parts of the actions, if any. + * @return string[] The ids of the actions registered, in the order of the + * call. Empty when none is, when called outside the + * `wp_entity_actions_api_init` action, or when an argument is + * invalid. + */ + public function register( $origin, $kind, $name, $actions, $script_module = null ) { + if ( ! $this->validate_arguments( __METHOD__, $origin, $kind, $name, $actions, $script_module ) ) { + return array(); + } + $actions = $this->skip_invalid_definitions( __METHOD__, $kind, $name, $actions ); + if ( ! $actions ) { + return array(); + } + + $new_actions = array(); + $duplicated = array(); + $registered_ids = array(); + foreach ( $actions as $action ) { + $id = $action['id']; + if ( isset( $this->actions[ $kind ][ $name ][ $id ] ) ) { + $registered_ids[ $id ] = $id; + } elseif ( isset( $new_actions[ $id ] ) ) { + $duplicated[ $id ] = $id; + } else { + $new_actions[ $id ] = $action; + } + } + + 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 action ids. */ + __( 'An action can only be registered once. These actions 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 action ids. */ + __( 'An action can only be registered once. These actions of %1$s "%2$s" are skipped because they are already registered: %3$s. Unregister an action first to replace it.' ), + $kind, + $name, + implode( ', ', $registered_ids ) + ), + '7.2.0' + ); + } + + if ( ! $new_actions ) { + return array(); + } + $new_actions = $this->report_invalid_values( __METHOD__, $kind, $name, $new_actions ); + + foreach ( $new_actions as $action ) { + $this->actions[ $kind ][ $name ][ $action['id'] ] = array_merge( + $action, + array( + 'origin' => array( + 'registeredBy' => $origin, + ), + ) + ); + } + $ids = array_column( $new_actions, 'id' ); + $this->add_action_module( $kind, $name, $ids, $script_module ); + + return $ids; + } + + /** + * Reports the values of action definitions that DataViews does not + * support, and the properties only a script module can provide, with + * _doing_it_wrong(). + * + * A value DataViews does not support is otherwise silent: an action + * whose `context` is not `list` or `single` shows on no screen. Such a + * value is kept, like the field types the fields registry reports. + * `callback`, `RenderModal`, and `isEligible` are functions and + * components, which only a script module can provide: a value set on + * the server would be called on the client and fail, so it is removed. + * + * @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[] $actions The action definitions, keyed by id. + * @return array[] The definitions, without the properties only a script + * module can provide. + */ + protected function report_invalid_values( $method, $kind, $name, $actions ) { + $invalid = array(); + foreach ( $actions as $id => $action ) { + foreach ( self::SUPPORTED_VALUES as $property => $values ) { + if ( array_key_exists( $property, $action ) && ! in_array( $action[ $property ], $values, true ) ) { + $invalid[] = sprintf( + '%1$s (%2$s)', + $id, + wp_sprintf( + /* translators: 1: Property name, e.g. context. 2: List of the values DataViews supports. */ + __( '%1$s is not one of %2$l.' ), + $property, + $values + ) + ); + } + } + foreach ( array( 'callback', 'RenderModal', 'isEligible' ) as $property ) { + if ( array_key_exists( $property, $action ) ) { + unset( $actions[ $id ][ $property ] ); + $invalid[] = sprintf( + /* translators: 1: Action id. 2: Property name, e.g. callback. */ + __( '%1$s (%2$s can only come from a script module, so it is removed.)' ), + $id, + $property + ); + } + } + } + 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 action ids, each with the error of a value. */ + __( 'These actions of %1$s "%2$s" have values DataViews does not support: %3$s' ), + $kind, + $name, + implode( ', ', $invalid ) + ), + '7.2.0' + ); + } + + return $actions; + } + + /** + * Unregisters actions of an entity. + * + * Unregistering an action removes its registered definition and drops + * it from the script modules that applied to it (a module left with no + * action is forgotten). Unregistering every action forgets the entity: + * its registered actions and script modules. + * + * Like register(), it only runs on the `wp_entity_actions_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 actions to unregister. + * Default null, every action of the entity. + * @return array[] The list of the definitions unregistered, in + * registration order, as get_registered() returns them. + * Empty when none of the actions is registered, when + * called outside the `wp_entity_actions_api_init` action, or + * when the entity is invalid. + */ + public function unregister( $kind, $name, $ids = null ) { + if ( ! $this->doing_entity_actions_api_init( __METHOD__ ) || ! $this->validate_entity( __METHOD__, $kind, $name ) ) { + return array(); + } + + if ( null === $ids ) { + $unregistered = array_values( $this->actions[ $kind ][ $name ] ?? array() ); + unset( $this->actions[ $kind ][ $name ], $this->action_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->actions[ $kind ][ $name ] ?? array(), array_flip( $ids ) ); + if ( empty( $unregistered ) ) { + return array(); + } + + // A script module only ever applies to registered actions: register() + // adds it for the actions it stores, and actions leave both lists + // together here. So the actions removed are all the registry loses, + // and the modules are not returned: the ones that applied to them are + // listed by get_registered_action_modules(), read beforehand. + $ids = array_keys( $unregistered ); + foreach ( $ids as $id ) { + unset( $this->actions[ $kind ][ $name ][ $id ] ); + } + foreach ( $this->action_modules[ $kind ][ $name ] ?? array() as $module => $module_ids ) { + $remaining = array_values( array_diff( $module_ids, $ids ) ); + if ( empty( $remaining ) ) { + unset( $this->action_modules[ $kind ][ $name ][ $module ] ); + } else { + $this->action_modules[ $kind ][ $name ][ $module ] = $remaining; + } + } + $this->forget_empty_entity( $kind, $name ); + + return array_values( $unregistered ); + } + + /** + * Returns the actions 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 action definitions, in registration order. + * Empty when the entity is invalid. + */ + public function get_registered( $kind, $name ) { + if ( ! $this->validate_entity( __METHOD__, $kind, $name ) ) { + return array(); + } + $this->initialize(); + return array_values( $this->actions[ $kind ][ $name ] ?? array() ); + } + + /** + * Returns the actions registered for every entity. + * + * @since 7.2.0 + * + * @return array> The lists of action + * 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->actions + ); + } + + /** + * 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 actions each module + * applies to, keyed by module id, in + * registration order. Empty when the + * entity is invalid. + */ + public function get_registered_action_modules( $kind, $name ) { + if ( ! $this->validate_entity( __METHOD__, $kind, $name ) ) { + return array(); + } + $this->initialize(); + return $this->action_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_action_modules() { + $this->initialize(); + return array_map( + static function ( $entities ) { + return array_map( + static function ( $modules ) { + // A numeric module id is an integer as an array key. + return array_map( 'strval', array_keys( $modules ) ); + }, + $entities + ); + }, + $this->action_modules + ); + } + + /** + * Fires the `wp_entity_actions_api_init` action the first time the registry + * is read. + * + * The read is refused until `init` has completed. The registry holds the + * actions 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 actions of a post type can derive from its supports, and the + * action fires once, so a read before or during `init` would fix them + * from the post types registered so far for the rest of the request. + * + * did_action() is true from the first `init` callback on, hence the + * doing_action() check for the reads made while it runs. + * + * @since 7.2.0 + */ + protected function initialize() { + if ( $this->initialized ) { + return; + } + + if ( ! did_action( 'init' ) || doing_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The registered actions cannot be read until the `init` action has completed: the post types and supports they derive from are registered on `init`. Read them once `init` has completed, or hook `wp_entity_actions_api_init`.' ), + '7.2.0' + ); + return; + } + + // Set before firing, so a callback reading the registry to inspect + // the actions it unregisters does not fire the action again. + $this->initialized = true; + + $this->initializing = true; + try { + /** + * Fires the first time the registered actions are read, after `init`. + * + * Register or remove actions here, with wp_register_entity_actions() and + * wp_unregister_entity_actions(): it is the only time they run. + * + * The first read happens wherever the actions 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 actions + * and nothing else; script modules, styles, and their enqueue hooks + * belong on `init`. + * + * @since 7.2.0 + */ + do_action( 'wp_entity_actions_api_init' ); + } finally { + $this->initializing = false; + } + } + + /** + * Checks that the registry is firing the `wp_entity_actions_api_init` action, + * as register() and unregister() require. + * + * Outside the action a registration would either come before the + * registrations made on it, and take the ids they register, or after + * the actions have been read and the import map of the editor script + * built from them. Refusing keeps the registry immutable once read, + * even when the action is fired again with do_action(), which is why + * the registry tracks its own firing rather than check doing_action(). + * + * @since 7.2.0 + * + * @param string $method The calling method, for the notice. + * @return bool Whether the action is firing. + */ + protected function doing_entity_actions_api_init( $method ) { + if ( $this->initializing ) { + return true; + } + + _doing_it_wrong( + $method, + __( 'Actions can only be registered and unregistered on the `wp_entity_actions_api_init` action.' ), + '7.2.0' + ); + return false; + } + + /** + * Validates the arguments of register(). + * + * @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 $actions The action definitions. + * @param mixed $script_module The script module id, if any. + * @return bool Whether the call may proceed: the `wp_entity_actions_api_init` + * action is firing and the arguments are valid. + */ + protected function validate_arguments( $method, $origin, $kind, $name, $actions, $script_module ) { + if ( ! $this->doing_entity_actions_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( $actions ) || empty( $actions ) || ! array_is_list( $actions ) ) { + _doing_it_wrong( + $method, + __( 'The actions must be a list of non-empty action 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 action, an invalid definition does not cost the call + * its other actions. + * + * @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[] $actions The list of action definitions. + * @return array[] The valid definitions, in the order of the call. + */ + protected function skip_invalid_definitions( $method, $kind, $name, $actions ) { + $valid = array(); + $invalid = array(); + foreach ( $actions as $position => $action ) { + if ( is_array( $action ) && isset( $action['id'] ) && is_string( $action['id'] ) && '' !== $action['id'] ) { + $valid[] = $action; + } else { + $invalid[] = '#' . ( $position + 1 ); + } + } + + if ( $invalid ) { + $message = 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 action definition must be an array with a non-empty string `id`. These definitions of %1$s "%2$s" are skipped: %3$s.' ), + $kind, + $name, + implode( ', ', $invalid ) + ); + if ( $valid ) { + $message .= ' ' . __( 'The rest of the actions of the call are stored.' ); + } + _doing_it_wrong( $method, $message, '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 actions. + * @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 ) { + $message = 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 action id must be a non-empty string. These ids of %1$s "%2$s" are skipped: %3$s.' ), + $kind, + $name, + implode( ', ', $invalid ) + ); + if ( $valid ) { + $message .= ' ' . __( 'The rest of the actions of the call are unregistered.' ); + } + _doing_it_wrong( $method, $message, '7.2.0' ); + } + + return $valid; + } + + /** + * Applies a script module to actions of an entity, after the actions 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 actions. + * @param string|null $script_module The id of the script module, if any. + */ + protected function add_action_module( $kind, $name, $ids, $script_module ) { + if ( null === $script_module ) { + return; + } + + $module_ids = $this->action_modules[ $kind ][ $name ][ $script_module ] ?? array(); + foreach ( $ids as $id ) { + if ( ! in_array( $id, $module_ids, true ) ) { + $module_ids[] = $id; + } + } + $this->action_modules[ $kind ][ $name ][ $script_module ] = $module_ids; + } + + /** + * Forgets an entity left without actions 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->actions[ $kind ][ $name ] ) ) { + unset( $this->actions[ $kind ][ $name ] ); + } + if ( empty( $this->actions[ $kind ] ) ) { + unset( $this->actions[ $kind ] ); + } + if ( empty( $this->action_modules[ $kind ][ $name ] ) ) { + unset( $this->action_modules[ $kind ][ $name ] ); + } + if ( empty( $this->action_modules[ $kind ] ) ) { + unset( $this->action_modules[ $kind ] ); + } + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index bf8ff15df4c36..6a635df738812 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -853,4 +853,7 @@ } add_filter( 'get_entity_view_config_root_site', '_wp_get_entity_view_config_root_site', 5 ); +// Entity Actions API. +add_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script', 10, 0 ); + unset( $filter, $action, $post_type ); diff --git a/src/wp-includes/entity-actions.php b/src/wp-includes/entity-actions.php new file mode 100644 index 0000000000000..6496048f71d9e --- /dev/null +++ b/src/wp-includes/entity-actions.php @@ -0,0 +1,188 @@ +register( $origin, $kind, $name, $actions, $script_module ); +} + +/** + * Unregisters actions of an entity, see + * {@see WP_Entity_Actions_Registry::unregister()}. + * + * It only runs on the `wp_entity_actions_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 actions to unregister. Default + * null, every action of the entity. + * @return array[] The list of the definitions unregistered, in + * registration order. Empty when none is. + */ +function wp_unregister_entity_actions( $kind, $name, $ids = null ) { + return WP_Entity_Actions_Registry::get_instance()->unregister( $kind, $name, $ids ); +} + +/** + * Returns the actions 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 action definitions, in + * registration order. Empty until the `init` action has + * completed: a read before then is refused. + */ +function wp_get_registered_entity_actions( $kind, $name ) { + return WP_Entity_Actions_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 actions 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_action_modules( $kind, $name ) { + return WP_Entity_Actions_Registry::get_instance()->get_registered_action_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_action_modules() { + return WP_Entity_Actions_Registry::get_instance()->get_all_registered_action_modules(); +} + +/** + * Declares the script modules of the entity actions 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_actions_api_init`, so the actions 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 post types and supports the actions derive from are + * final. + * The registry only accepts registrations while the action fires, so the + * modules declared here are the modules of every action the page can show. + * + * The `admin_footer` action passes it no argument, so the global registry is + * used. In other scenarios (tests), the scripts are given directly. + * + * @since 7.2.0 + * + * @param WP_Scripts|null $scripts The scripts registry. Defaults to the global one. + */ +function _wp_add_entity_action_modules_to_editor_script( $scripts = null ) { + if ( ! $scripts instanceof WP_Scripts ) { + $scripts = wp_scripts(); + } + + // The actions are loaded by `usePostActions()` of the editor script, so + // every screen showing them loads it. + if ( ! $scripts->query( 'wp-editor', 'enqueued' ) && ! $scripts->query( 'wp-editor', 'done' ) ) { + return; + } + + $kinds = wp_get_all_registered_entity_action_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 ); + } +} diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d8517d2ddff71..2861e08b5868b 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -436,6 +436,10 @@ function create_initial_rest_routes() { // View Config. $view_config_controller = new WP_REST_View_Config_Controller(); $view_config_controller->register_routes(); + + // Entity Actions. + $entity_actions_controller = new WP_REST_Entity_Actions_Controller(); + $entity_actions_controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php new file mode 100644 index 0000000000000..b2f96e8418c1a --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php @@ -0,0 +1,354 @@ +namespace = 'wp/v2'; + $this->rest_base = 'entity-actions'; + } + + /** + * Registers the routes for the controller. + * + * @since 7.2.0 + */ + public function register_routes() { + if ( $this->is_route_of_entity_items() ) { + return; + } + + 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', + 'minLength' => 1, + 'required' => true, + ), + 'name' => array( + 'description' => __( 'Entity name.' ), + 'type' => 'string', + 'minLength' => 1, + 'required' => true, + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether a post type or taxonomy has its items on the route of + * the controller, reporting it with _doing_it_wrong(). + * + * The route is then left to the post type or taxonomy: registering it + * as well would not serve the actions, as the route of the items comes + * first, and would replace the schema of that route. + * + * @since 7.2.0 + * + * @return bool Whether a post type or taxonomy has its items on the route. + */ + protected function is_route_of_entity_items() { + $objects = array_merge( + array_values( get_post_types( array( 'show_in_rest' => true ), 'objects' ) ), + array_values( get_taxonomies( array( 'show_in_rest' => true ), 'objects' ) ) + ); + foreach ( $objects as $object ) { + $namespace = $object->rest_namespace ? $object->rest_namespace : 'wp/v2'; + $base = $object->rest_base ? $object->rest_base : $object->name; + if ( $this->namespace === $namespace && $this->rest_base === $base ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: REST route, e.g. /wp/v2/entity-actions. 2: Post type or taxonomy name. */ + __( 'The %1$s route serves the items of the "%2$s" post type or taxonomy, so the actions of entities are not available.' ), + '/' . $this->namespace . '/' . $this->rest_base, + $object->name + ), + '7.2.0' + ); + return true; + } + } + + return false; + } + + /** + * Checks if a given request has access to read the actions 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_actions_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 actions of this entity.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Resolves the capability required to read the actions of an entity. + * + * Mirrors the fields 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 actions 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 actions, 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 actions 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' ); + + $script_modules = array(); + foreach ( wp_get_registered_entity_action_modules( $kind, $name ) as $module => $action_ids ) { + $script_modules[] = array( + // A numeric module id is an integer as an array key. + 'id' => (string) $module, + 'actions' => array_values( $action_ids ), + ); + } + + $response = array( + 'kind' => $kind, + 'name' => $name, + 'actions' => wp_get_registered_entity_actions( $kind, $name ), + '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-actions', + 'type' => 'object', + 'properties' => array( + 'kind' => array( + 'description' => __( 'Entity kind.' ), + 'type' => 'string', + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Entity name.' ), + 'type' => 'string', + 'readonly' => true, + ), + 'actions' => array( + 'description' => __( 'The actions registered for the entity, in registration order.' ), + 'type' => 'array', + 'readonly' => true, + 'items' => $this->get_action_schema(), + ), + 'script_modules' => array( + 'description' => __( 'The script modules providing the JavaScript parts of the actions, each with the ids of the actions it applies to.' ), + 'type' => 'array', + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'The id of the script module.' ), + 'type' => 'string', + ), + 'actions' => array( + 'description' => __( 'The ids of the actions the script module applies to.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Returns the schema for an action definition. + * + * Describes the serializable subset of the DataViews Action API, see + * packages/dataviews/src/types/dataviews.ts. The properties that are + * JavaScript callbacks, components, or elements (`callback`, + * `RenderModal`, `isEligible`, `icon`, …) come from the script modules and + * are not part of the definition. Additional properties are allowed, so a + * plugin can register an action with properties of its own that its script + * module understands. + * + * @since 7.2.0 + * + * @return array Schema for an action definition. + */ + protected function get_action_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'The unique identifier of the action.' ), + 'type' => 'string', + ), + 'origin' => array( + 'description' => __( 'Who registered the action: `core`, or the slug of a plugin or theme.' ), + 'type' => 'object', + 'properties' => array( + 'registeredBy' => array( + 'description' => __( 'Who registered the action.' ), + 'type' => 'string', + ), + ), + ), + 'label' => array( + 'description' => __( 'The label of the action.' ), + 'type' => 'string', + ), + 'isPrimary' => array( + 'description' => __( 'Whether the action is a primary action.' ), + 'type' => 'boolean', + ), + 'supportsBulk' => array( + 'description' => __( 'Whether the action can be performed on several items at once.' ), + 'type' => 'boolean', + ), + 'disabled' => array( + 'description' => __( 'Whether the action is disabled.' ), + 'type' => 'boolean', + ), + 'context' => array( + 'description' => __( 'The context the action is limited to, such as `list` for the lists of items. Without it, the action shows everywhere.' ), + 'type' => 'string', + 'enum' => array( 'list', 'single' ), + ), + 'modalHeader' => array( + 'description' => __( 'The header of the modal of the action.' ), + 'type' => 'string', + ), + 'hideModalHeader' => array( + 'description' => __( 'Whether to hide the header of the modal of the action.' ), + 'type' => 'boolean', + ), + 'modalSize' => array( + 'description' => __( 'The size of the modal of the action.' ), + 'type' => 'string', + 'enum' => array( 'small', 'medium', 'large', 'fill' ), + ), + 'modalFocusOnMount' => array( + 'description' => __( 'Where the focus goes when the modal of the action opens.' ), + 'type' => array( 'boolean', 'string' ), + ), + ), + 'additionalProperties' => true, + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 0340582015899..107e984a74cd1 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -206,6 +206,8 @@ require ABSPATH . WPINC . '/template.php'; require ABSPATH . WPINC . '/class-wp-view-config-data.php'; require ABSPATH . WPINC . '/view-config.php'; +require ABSPATH . WPINC . '/class-wp-entity-actions-registry.php'; +require ABSPATH . WPINC . '/entity-actions.php'; require ABSPATH . WPINC . '/https-detection.php'; require ABSPATH . WPINC . '/https-migration.php'; require ABSPATH . WPINC . '/class-wp-user-request.php'; @@ -379,6 +381,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.php'; 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-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php'; diff --git a/tests/phpunit/tests/entity-actions.php b/tests/phpunit/tests/entity-actions.php new file mode 100644 index 0000000000000..70bc7cf6fb9ae --- /dev/null +++ b/tests/phpunit/tests/entity-actions.php @@ -0,0 +1,925 @@ + 'rename-post', + 'label' => 'Rename…', + ), + array( + 'id' => 'move-to-trash', + 'label' => 'Trash…', + 'isPrimary' => true, + ), + ), + 'core/defaults' + ); + } + + /** + * Resets the registry: drops the singleton instance, so the next + * get_instance() creates an empty registry and its first read fires + * `wp_entity_actions_api_init` again. + */ + private static function reset_registry() { + $instance = new ReflectionProperty( WP_Entity_Actions_Registry::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); + } + + /** + * Hooks a callback to `wp_entity_actions_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 defaults are registered again and + * the callbacks hooked so far replay, in order. + * + * @param callable $callback The callback. + * @param int $priority The priority. Default 10, after the defaults. + */ + private function on_entity_actions_api_init( $callback, $priority = 10 ) { + add_action( 'wp_entity_actions_api_init', $callback, $priority ); + + self::reset_registry(); + WP_Entity_Actions_Registry::get_instance()->get_all_registered(); + } + + /** + * Unregisters the actions of every entity with a script module, so a + * test can start from a registry without modules. The registry is reset + * on tear down. + */ + private function unregister_all_action_modules() { + $this->on_entity_actions_api_init( + static function () { + foreach ( wp_get_all_registered_entity_action_modules() as $kind => $entities ) { + foreach ( array_keys( $entities ) as $name ) { + wp_unregister_entity_actions( $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() + ); + } + + /** + * Registers actions on `wp_entity_actions_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[] $actions The action definitions. + * @param string|null $module The script module id, if any. + * @return string[] The ids of the actions registered. + */ + private function register_actions( $kind, $name, $actions, $module = null ) { + $registered = array(); + $this->on_entity_actions_api_init( + static function () use ( &$registered, $kind, $name, $actions, $module ) { + $registered = wp_register_entity_actions( 'test-plugin', $kind, $name, $actions, $module ); + } + ); + return $registered; + } + + /** + * Builds a minimal action definition. + * + * @param string $id The action id. + * @return array The action definition. + */ + private function action( $id ) { + return array( + 'id' => $id, + 'label' => $id, + ); + } + + /** + * Records the messages of the notices a method reports with + * _doing_it_wrong(), for the rest of the test. + * + * @param string $method The method, e.g. `WP_Entity_Actions_Registry::register`. + * @return string[] The messages, filled as the notices are reported. + */ + private 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; + } + + /** + * 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. + */ + public function test_the_modules_are_added_on_admin_footer() { + $this->assertSame( 10, has_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script' ) ); + } + + /** + * A page that registers the editor script without loading it is left + * untouched, and the registry is not read. + */ + 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_actions_api_init' ); + self::reset_registry(); + _wp_add_entity_action_modules_to_editor_script( $scripts ); + + $this->assertSame( array(), $scripts->get_data( 'wp-editor', 'module_dependencies' ) ); + $this->assertSame( $fired, did_action( 'wp_entity_actions_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`. + */ + public function test_the_modules_are_added_when_the_editor_script_is_a_dependency() { + $this->unregister_all_action_modules(); + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); + + $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_action_modules_to_editor_script( $scripts ); + + $this->assertSame( array( 'plugin/feature' ), $this->get_module_dependency_ids( $scripts, 'wp-editor' ) ); + } + + /** + * A scripts registry without the editor script is left untouched, even + * when action script modules are registered. + */ + public function test_nothing_happens_without_the_editor_script() { + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); + + $scripts = new WP_Scripts(); + // The default scripts register the real editor script. + $scripts->remove( 'wp-editor' ); + _wp_add_entity_action_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. + */ + public function test_nothing_happens_without_registered_action_modules() { + $this->unregister_all_action_modules(); + $this->assertSame( array(), wp_get_all_registered_entity_action_modules(), 'No action 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_action_modules_to_editor_script( $scripts ); + + $this->assertSame( array(), $scripts->get_data( 'wp-editor', 'module_dependencies' ) ); + } + + /** + * The script module of an action becomes a dynamic dependency of the + * editor script. + */ + public function test_action_script_modules_become_dynamic_dependencies_of_the_editor_script() { + $this->unregister_all_action_modules(); + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); + + $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_action_modules_to_editor_script( $scripts ); + + $this->assertSame( + array( + array( + 'id' => 'plugin/feature', + '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. + */ + public function test_declared_dependencies_are_kept_and_modules_are_added_once() { + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); + + $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_action_modules_to_editor_script( $scripts ); + $dependencies = $scripts->get_data( 'wp-editor', 'module_dependencies' ); + _wp_add_entity_action_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/feature', true ) ), 'The module is declared once.' ); + $this->assertContains( + array( + 'id' => 'plugin/feature', + 'import' => 'dynamic', + ), + $dependencies + ); + } + + /** + * A module registered for several entities is declared once. + */ + public function test_a_module_shared_by_several_entities_is_declared_once() { + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/actions' ); + $this->register_actions( 'postType', 'post', array( $this->action( 'feature' ) ), 'plugin/actions' ); + + $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_action_modules_to_editor_script( $scripts ); + + $ids = $this->get_module_dependency_ids( $scripts, 'wp-editor' ); + $this->assertSame( array( 'plugin/actions' ), array_values( array_intersect( $ids, array( 'plugin/actions' ) ) ) ); + } + + /** + * The script modules of the actions reach the import map of a page that + * loads the editor script. + */ + public function test_action_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 built modules may not be + // registered in the test environment. + $this->unregister_all_action_modules(); + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); + $wp_scripts->add( 'wp-editor', '/editor.js' ); + wp_register_script_module( 'plugin/feature', '/feature.js' ); + + $wp_scripts->enqueue( 'wp-editor' ); + _wp_add_entity_action_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/feature' ); + } + + $this->assertArrayHasKey( 'plugin/feature', $import_map['imports'] ); + } + + /** + * Reading the registry fires `wp_entity_actions_api_init` once, whichever + * getter is read first and however many times it is read. + */ + public function test_reading_the_registry_fires_the_action_once() { + self::reset_registry(); + $registry = WP_Entity_Actions_Registry::get_instance(); + $fired = did_action( 'wp_entity_actions_api_init' ); + + wp_get_registered_entity_actions( 'postType', 'page' ); + $this->assertSame( $fired + 1, did_action( 'wp_entity_actions_api_init' ), 'The first read fires the action.' ); + + wp_get_registered_entity_actions( 'postType', 'post' ); + wp_get_registered_entity_action_modules( 'postType', 'page' ); + wp_get_all_registered_entity_action_modules(); + $registry->get_all_registered(); + $this->assertSame( $fired + 1, did_action( 'wp_entity_actions_api_init' ), 'Further reads do not fire it again.' ); + } + + /** + * Firing the action again once the registry has been read does not reopen + * it: the registrations made then are refused. + */ + public function test_firing_the_action_again_does_not_reopen_the_registry() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $actions = wp_get_registered_entity_actions( 'postType', 'page' ); + add_action( + 'wp_entity_actions_api_init', + function () { + wp_register_entity_actions( 'test-plugin', 'postType', 'page', array( $this->action( 'late' ) ) ); + } + ); + + do_action( 'wp_entity_actions_api_init' ); + + $this->assertSame( $actions, wp_get_registered_entity_actions( 'postType', 'page' ) ); + } + + /** + * A plugin hooking the action at the default priority sees the defaults: + * it can remove a default action, or replace it by unregistering it and + * registering it again with the script module of the default, which keeps + * its JavaScript parts. + */ + public function test_a_callback_at_the_default_priority_alters_the_defaults() { + $callback = static function () { + wp_unregister_entity_actions( 'postType', 'page', array( 'rename-post' ) ); + wp_unregister_entity_actions( 'postType', 'page', array( 'move-to-trash' ) ); + wp_register_entity_actions( + 'test-plugin', + 'postType', + 'page', + array( + array( + 'id' => 'move-to-trash', + 'label' => 'Trash…', + 'isPrimary' => false, + ), + ), + 'core/defaults' + ); + }; + add_action( 'wp_entity_actions_api_init', $callback ); + + self::reset_registry(); + $actions = wp_get_registered_entity_actions( 'postType', 'page' ); + $modules = wp_get_registered_entity_action_modules( 'postType', 'page' ); + + $this->assertSame( + array( + array( + 'id' => 'move-to-trash', + 'label' => 'Trash…', + 'isPrimary' => false, + 'origin' => array( 'registeredBy' => 'test-plugin' ), + ), + ), + $actions, + 'The removed action is gone, and the replaced one has the new definition and origin.' + ); + $this->assertSame( array( 'core/defaults' => array( 'move-to-trash' ) ), $modules, 'The replaced action keeps the script module of the default.' ); + } + + /** + * The origin an action is registered with is stored on its definition, + * over any `origin` the definition sets; the default actions come from + * `core`. + */ + public function test_actions_carry_the_origin_they_are_registered_with() { + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) + array( 'origin' => 'spoofed' ) ) ); + + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'origin', 'id' ); + $this->assertSame( array( 'registeredBy' => 'test-plugin' ), $actions['feature'] ); + $this->assertSame( array( 'registeredBy' => 'core' ), $actions['rename-post'] ); + } + + /** + * An action can only be registered once: an action with the id of a + * registered action is skipped and leaves it untouched, and the rest of + * the actions of the call are registered. + */ + public function test_registering_a_registered_action_is_skipped() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::register' ); + $registered = $this->register_actions( + 'postType', + 'page', + array( + $this->action( 'feature' ), + array( + 'id' => 'rename-post', + 'label' => 'Retitle…', + ), + ), + 'plugin/actions' + ); + + $this->assertSame( array( 'feature' ), $registered, 'The ids of the actions registered are returned.' ); + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertArrayHasKey( 'feature', $actions, 'The rest of the actions of the call are registered.' ); + $this->assertSame( 'Rename…', $actions['rename-post']['label'], 'The registered action is left untouched.' ); + $this->assertSame( 'core', $actions['rename-post']['origin']['registeredBy'] ); + $this->assertSame( array( 'feature' ), wp_get_registered_entity_action_modules( 'postType', 'page' )['plugin/actions'], 'The script module only applies to the actions registered.' ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( 'already registered: rename-post', $reported[0], 'The notice names the action that is already registered, not the others of the call.' ); + } + + /** + * A `context` or `modalSize` DataViews does not support is reported, and + * registered anyway. A property only a script module can provide is + * reported and removed. The other actions of the call are not reported. + */ + public function test_registering_an_action_with_invalid_values_is_reported() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::register' ); + $registered = $this->register_actions( + 'postType', + 'page', + array( + $this->action( 'feature' ), + array( + 'id' => 'archive', + 'label' => 'Archive', + 'context' => 'lists', + 'isEligible' => true, + ), + array( + 'id' => 'export', + 'label' => 'Export', + 'modalSize' => 'huge', + ), + ) + ); + + $this->assertSame( array( 'feature', 'archive', 'export' ), $registered, 'The actions are registered anyway.' ); + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertSame( 'lists', $actions['archive']['context'], 'A value DataViews does not support is kept.' ); + $this->assertSame( 'huge', $actions['export']['modalSize'], 'A value DataViews does not support is kept.' ); + $this->assertArrayNotHasKey( 'isEligible', $actions['archive'], 'A property only a script module can provide is removed.' ); + $this->assertCount( 1, $reported ); + $this->assertStringContainsString( 'postType "page"', $reported[0], 'The notice names the entity.' ); + $this->assertStringContainsString( ': archive (context is not one of list and single.)', $reported[0], 'The notice names the action and its value DataViews does not support.' ); + $this->assertStringContainsString( 'archive (isEligible can only come from a script module', $reported[0], 'The notice names the property that is removed.' ); + $this->assertStringContainsString( 'export (modalSize is not one of small, medium, large, and fill.)', $reported[0], 'The notice names every value DataViews does not support.' ); + $this->assertStringNotContainsString( 'feature', $reported[0], 'The other actions of the call are not reported.' ); + } + + /** + * An id that appears more than once in a call is registered once, with + * its first definition, and the rest of the actions of the call are + * registered. + */ + public function test_registering_the_same_action_twice_keeps_the_first_definition() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::register' ); + $registered = $this->register_actions( + 'postType', + 'page', + array( + $this->action( 'feature' ), + array( + 'id' => 'feature', + 'label' => 'Highlight', + ), + $this->action( 'archive' ), + ) + ); + + $this->assertSame( array( 'feature', 'archive' ), $registered ); + $actions = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), null, 'id' ); + $this->assertSame( 'feature', $actions['feature']['label'], 'The first definition is kept.' ); + $this->assertArrayHasKey( 'archive', $actions, 'The rest of the actions 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: feature', $reported[0], 'The notice names the duplicated action.' ); + } + + /** + * The actions of an entity keep their registration order: the order the + * client shows them in. + */ + public function test_actions_keep_their_registration_order() { + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ), $this->action( 'archive' ) ), 'plugin/actions' ); + $this->register_actions( 'postType', 'page', array( $this->action( 'share' ) ), 'other-plugin/actions' ); + + $this->assertSame( + array( 'rename-post', 'move-to-trash', 'feature', 'archive', 'share' ), + array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ) + ); + $this->assertSame( + array( + 'core/defaults' => array( 'rename-post', 'move-to-trash' ), + 'plugin/actions' => array( 'feature', 'archive' ), + 'other-plugin/actions' => array( 'share' ), + ), + wp_get_registered_entity_action_modules( 'postType', 'page' ), + 'Each module applies to the actions it was registered with, in registration order.' + ); + } + + /** + * 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 actions and + * script modules do not mix. + */ + public function test_entities_whose_kind_and_name_join_alike_are_kept_apart() { + $result = array(); + $this->on_entity_actions_api_init( + function () use ( &$result ) { + $result['first'] = wp_register_entity_actions( 'test-plugin', 'a/b', 'c', array( $this->action( 'feature' ) ), 'plugin/first' ); + $result['second'] = wp_register_entity_actions( 'test-plugin', 'a', 'b/c', array( $this->action( 'feature' ), $this->action( 'archive' ) ), 'plugin/second' ); + $result['unregistered'] = wp_unregister_entity_actions( 'a/b', 'c' ); + } + ); + + $this->assertSame( array( 'feature' ), $result['first'], 'The first entity registers its action.' ); + $this->assertSame( array( 'feature', 'archive' ), $result['second'], 'The second entity registers an action with the same id.' ); + $this->assertSame( array( 'feature' ), array_column( $result['unregistered'], 'id' ), 'Unregistering the first entity only removes its own action.' ); + $this->assertSame( array(), wp_get_registered_entity_actions( 'a/b', 'c' ) ); + $this->assertSame( array( 'feature', 'archive' ), array_column( wp_get_registered_entity_actions( 'a', 'b/c' ), 'id' ), 'The second entity keeps its actions.' ); + $this->assertSame( array( 'plugin/second' => array( 'feature', 'archive' ) ), wp_get_registered_entity_action_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_action_modules()['a'], 'Only the second entity has modules under its kind.' ); + $this->assertArrayNotHasKey( 'a/b', wp_get_all_registered_entity_action_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. + * A module left without actions is forgotten. + */ + public function test_unregistering_returns_the_unregistered_actions() { + $unregistered = array(); + $modules = array(); + $this->on_entity_actions_api_init( + function () use ( &$unregistered, &$modules ) { + wp_register_entity_actions( 'test-plugin', 'test', 'entity', array( $this->action( 'a' ), $this->action( 'b' ) ), 'plugin/first' ); + wp_register_entity_actions( 'test-plugin', 'test', 'entity', array( $this->action( 'c' ) ), 'plugin/second' ); + $unregistered['by_id'] = wp_unregister_entity_actions( 'test', 'entity', array( 'c', 'missing', 'a' ) ); + $modules['by_id'] = wp_get_registered_entity_action_modules( 'test', 'entity' ); + $unregistered['missing'] = wp_unregister_entity_actions( 'test', 'entity', array( 'a' ) ); + $unregistered['every'] = wp_unregister_entity_actions( 'test', 'entity' ); + $unregistered['empty'] = wp_unregister_entity_actions( 'test', 'entity' ); + } + ); + + $origin = array( 'registeredBy' => 'test-plugin' ); + $this->assertSame( + array( + array_merge( $this->action( 'a' ), array( 'origin' => $origin ) ), + array_merge( $this->action( 'c' ), array( 'origin' => $origin ) ), + ), + $unregistered['by_id'], + 'The actions unregistered by id are returned in registration order.' + ); + $this->assertSame( array( 'plugin/first' => array( 'b' ) ), $modules['by_id'], 'The unregistered actions leave their modules, and an emptied module is forgotten.' ); + $this->assertSame( array(), $unregistered['missing'], 'Nothing is returned when no action is registered.' ); + $this->assertSame( + array( array_merge( $this->action( 'b' ), array( 'origin' => $origin ) ) ), + $unregistered['every'], + 'Unregistering every action returns them all.' + ); + $this->assertSame( array(), $unregistered['empty'], 'Nothing is returned for an entity without actions.' ); + $this->assertSame( array(), wp_get_registered_entity_actions( 'test', 'entity' ), 'The entity has no actions left.' ); + $this->assertArrayNotHasKey( 'test', wp_get_all_registered_entity_action_modules(), 'The entity has no script module left.' ); + } + + /** + * A definition that is not an array with a non-empty string `id` is + * reported by its position and skipped, like a duplicated action: the + * rest of the actions of the call are registered. + */ + public function test_an_invalid_definition_is_skipped() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::register' ); + $registered = $this->register_actions( + 'postType', + 'page', + array( + $this->action( 'feature' ), + 'archive', + array( 'label' => 'No id' ), + array( + 'id' => 7, + 'label' => 'Numeric id', + ), + $this->action( 'share' ), + $this->action( '0' ), + ) + ); + + $this->assertSame( array( 'feature', 'share', '0' ), $registered, 'The valid definitions are registered, `0` included.' ); + $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.' ); + $this->assertStringContainsString( 'The rest of the actions of the call are stored.', $reported[0] ); + } + + /** + * A call whose definitions are all invalid registers nothing, and its + * notice does not mention the rest of the call. + */ + public function test_a_call_with_only_invalid_definitions_registers_nothing() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::register' ); + $registered = $this->register_actions( 'postType', 'page', array( array( 'label' => 'No id' ) ) ); + + $this->assertSame( array(), $registered ); + $this->assertCount( 1, $reported ); + $this->assertStringNotContainsString( 'The rest of the actions', $reported[0] ); + } + + /** + * An origin that is not a non-empty string is refused. + */ + public function test_registering_with_an_invalid_origin_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $registered = null; + $this->on_entity_actions_api_init( + static function () use ( &$registered ) { + $registered = wp_register_entity_actions( '', 'postType', 'page', array( array( 'id' => 'feature' ) ) ); + } + ); + + $this->assertSame( array(), $registered ); + $this->assertNotContains( 'feature', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ) ); + } + + /** + * Actions that are not a list, such as actions keyed by id, are refused, + * and so is an empty list or a script module that is not an id. + */ + public function test_registering_actions_that_are_not_a_list_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::register' ); + $results = array(); + $this->on_entity_actions_api_init( + static function () use ( &$results ) { + $results['keyed'] = wp_register_entity_actions( 'test-plugin', 'postType', 'page', array( 'feature' => array( 'id' => 'feature' ) ) ); + $results['empty'] = wp_register_entity_actions( 'test-plugin', 'postType', 'page', array(), 'plugin/actions' ); + $results['module'] = wp_register_entity_actions( 'test-plugin', 'postType', 'page', array( array( 'id' => 'feature' ) ), 7 ); + } + ); + + $this->assertSame( + array( + 'keyed' => array(), + 'empty' => array(), + 'module' => array(), + ), + $results + ); + $this->assertCount( 3, $reported ); + $this->assertNotContains( 'feature', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ) ); + $this->assertArrayNotHasKey( 'plugin/actions', wp_get_registered_entity_action_modules( 'postType', 'page' ) ); + } + + /** + * 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. + */ + public function test_unregistering_with_an_invalid_entity_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); + $results = array(); + $this->on_entity_actions_api_init( + static function () use ( &$results ) { + $results['no name'] = wp_unregister_entity_actions( 'postType', array( 'rename-post' ) ); + $results['empty kind'] = wp_unregister_entity_actions( '', 'page', array( 'rename-post' ) ); + } + ); + + $this->assertSame( + array( + 'no name' => array(), + 'empty kind' => array(), + ), + $results + ); + $this->assertContains( 'rename-post', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ), 'The default action is kept.' ); + } + + /** + * An id that is not a non-empty string is reported and skipped: the + * other ids of the call are unregistered. + */ + public function test_unregistering_with_an_invalid_id_skips_it() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); + $unregistered = null; + $this->on_entity_actions_api_init( + static function () use ( &$unregistered ) { + $unregistered = wp_unregister_entity_actions( 'postType', 'page', array( array( 'id' => 'rename-post' ), '', 'move-to-trash' ) ); + } + ); + + $this->assertSame( array( 'move-to-trash' ), array_column( $unregistered, 'id' ) ); + $ids = array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ); + $this->assertContains( 'rename-post', $ids, 'A definition in place of an id is skipped, not matched.' ); + $this->assertNotContains( 'move-to-trash', $ids ); + } + + /** + * A call whose ids are all invalid unregisters nothing, and its notice + * does not mention the rest of the call. + */ + public function test_unregistering_only_invalid_ids_unregisters_nothing() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); + $reported = &$this->record_notices( 'WP_Entity_Actions_Registry::unregister' ); + $unregistered = null; + $this->on_entity_actions_api_init( + static function () use ( &$unregistered ) { + $unregistered = wp_unregister_entity_actions( 'postType', 'page', array( '' ) ); + } + ); + + $this->assertSame( array(), $unregistered ); + $this->assertCount( 2, wp_get_registered_entity_actions( 'postType', 'page' ) ); + $this->assertCount( 1, $reported ); + $this->assertStringNotContainsString( 'The rest of the actions', $reported[0] ); + } + + /** + * Reading the actions or the script modules of an entity whose kind or + * name is not a non-empty string is reported, and returns nothing. + */ + public function test_reading_an_invalid_entity_is_reported() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::get_registered' ); + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::get_registered_action_modules' ); + + $this->assertSame( array(), wp_get_registered_entity_actions( 'postType', array( 'page' ) ) ); + $this->assertSame( array(), wp_get_registered_entity_action_modules( '', 'page' ) ); + } + + /** + * A numeric script module id is read back as a string, like any other. + */ + public function test_a_numeric_module_id_is_read_as_a_string() { + $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), '123' ); + + $this->assertSame( array( 'core/defaults', '123' ), wp_get_all_registered_entity_action_modules()['postType']['page'] ); + } + + /** + * 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 actions have been + * read and the import map of the editor script built from them. + */ + public function test_registering_outside_the_action_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_actions_api_init' ); + + $this->assertSame( array(), wp_register_entity_actions( 'test-plugin', 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ) ); + $this->assertSame( $fired, did_action( 'wp_entity_actions_api_init' ), 'Refusing does not fire the action.' ); + $this->assertNotContains( 'feature', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ) ); + $this->assertArrayNotHasKey( 'plugin/feature', wp_get_registered_entity_action_modules( 'postType', 'page' ) ); + } + + /** + * 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 action nor fires the action early against + * post types still being registered. + */ + public function test_unregistering_outside_the_action_is_refused() { + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_actions_api_init' ); + + $this->assertSame( array(), wp_unregister_entity_actions( 'postType', 'page', array( 'rename-post' ) ), 'Unregistering actions by id is refused.' ); + $this->assertSame( array(), wp_unregister_entity_actions( 'postType', 'page' ), 'Unregistering every action is refused.' ); + $this->assertSame( $fired, did_action( 'wp_entity_actions_api_init' ), 'Refusing does not fire the action.' ); + $this->assertContains( 'rename-post', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ), 'The default action 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. + */ + public function test_reading_before_init_does_not_fire_the_action() { + global $wp_actions; + + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::initialize' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_actions_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_actions( 'postType', 'page' ) ); + $this->assertSame( array(), wp_get_all_registered_entity_action_modules() ); + $this->assertSame( $fired, did_action( 'wp_entity_actions_api_init' ), 'A read before `init` does not fire the action.' ); + } finally { + $wp_actions['init'] = $did_init; + } + + $this->assertContains( 'rename-post', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ), 'The next read fires the action and registers the defaults.' ); + $this->assertSame( $fired + 1, did_action( 'wp_entity_actions_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. + */ + public function test_reading_during_init_does_not_fire_the_action() { + global $wp_current_filter; + + $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::initialize' ); + self::reset_registry(); + $fired = did_action( 'wp_entity_actions_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_actions( 'postType', 'page' ) ); + $this->assertSame( array(), wp_get_all_registered_entity_action_modules() ); + $this->assertSame( $fired, did_action( 'wp_entity_actions_api_init' ), 'A read during `init` does not fire the action.' ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->assertContains( 'rename-post', array_column( wp_get_registered_entity_actions( 'postType', 'page' ), 'id' ), 'The next read fires the action and registers the defaults.' ); + $this->assertSame( $fired + 1, did_action( 'wp_entity_actions_api_init' ) ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php b/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php new file mode 100644 index 0000000000000..a543c6baca70b --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php @@ -0,0 +1,557 @@ +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 ); + } + + /** + * Tears down each test. + * + * Resetting the registry drops the actions a test registered; the next + * read fires `wp_entity_actions_api_init` again. The test case removes the + * callbacks the test hooked. + */ + public function tear_down() { + self::reset_registry(); + + parent::tear_down(); + } + + /** + * Resets the registry: drops the singleton instance, so the next + * get_instance() creates an empty registry and its first read fires + * `wp_entity_actions_api_init` again. + */ + private static function reset_registry() { + $instance = new ReflectionProperty( WP_Entity_Actions_Registry::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); + } + + /** + * Registers actions on `wp_entity_actions_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[] $actions The action definitions. + * @param string|null $module The script module id, if any. + * @return string[] The ids of the actions registered. + */ + private function register_actions( $kind, $name, $actions, $module = null ) { + $registered = array(); + $callback = static function () use ( &$registered, $kind, $name, $actions, $module ) { + $registered = wp_register_entity_actions( 'test-plugin', $kind, $name, $actions, $module ); + }; + add_action( 'wp_entity_actions_api_init', $callback ); + + self::reset_registry(); + WP_Entity_Actions_Registry::get_instance()->get_all_registered(); + + return $registered; + } + + /** + * Dispatches a request to the actions 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 + */ + 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_Actions_Controller', $routes[ self::ROUTE ][0]['callback'][0] ); + } + + /** + * A taxonomy whose items are on the route keeps it: the route of the + * actions is not registered, and that is reported. + * + * @covers ::register_routes + * @covers ::is_route_of_entity_items + */ + public function test_the_route_of_a_taxonomy_with_the_same_base_is_kept() { + global $wp_rest_server; + $this->setExpectedIncorrectUsage( 'WP_REST_Entity_Actions_Controller::is_route_of_entity_items' ); + register_taxonomy( 'entity-actions', 'post', array( 'show_in_rest' => true ) ); + + try { + $wp_rest_server = null; + $handlers = rest_get_server()->get_routes()[ self::ROUTE ]; + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', self::ROUTE ) ); + } finally { + unregister_taxonomy( 'entity-actions' ); + $wp_rest_server = null; + } + + $this->assertCount( 2, $handlers, 'Only the route of the terms is registered.' ); + $this->assertInstanceOf( WP_REST_Terms_Controller::class, $handlers[0]['callback'][0] ); + $this->assertArrayHasKey( 'POST', $handlers[1]['methods'], 'Terms can still be created.' ); + $this->assertSame( 200, $response->get_status(), 'The terms are listed.' ); + $this->assertSame( array(), $response->get_data() ); + } + + /** + * Editors (with `edit_posts`) can read the actions. + * + * @covers ::get_items_permissions_check + * @covers ::get_items + */ + public function test_get_items_allows_users_with_edit_posts() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request(); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Subscribers (without `edit_posts`) are forbidden. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_forbids_users_without_edit_posts() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_request(); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * Logged-out users are unauthorized. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + + $response = $this->dispatch_request(); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + /** + * Post type actions 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 + */ + 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 + */ + 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_actions_invalid_entity', $response, 404 ); + } + + /** + * The actions 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 + */ + public function test_get_items_post_type_hidden_from_rest_is_not_found() { + register_post_type( 'hidden_post_type', array( 'show_in_rest' => false ) ); + wp_set_current_user( self::$admin_id ); + + try { + $registered = $this->register_actions( + 'postType', + 'hidden_post_type', + array( + array( + 'id' => 'secret', + 'label' => 'Secret', + ), + ) + ); + $response = $this->dispatch_request( 'postType', 'hidden_post_type' ); + } finally { + unregister_post_type( 'hidden_post_type' ); + } + + $this->assertSame( array( 'secret' ), $registered, 'The registry accepts the actions.' ); + $this->assertErrorResponse( 'rest_entity_actions_invalid_entity', $response, 404 ); + } + + /** + * Taxonomy actions are gated by the taxonomy's `manage_terms` capability. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + public function test_get_items_uses_taxonomy_capability() { + 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 + */ + 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_actions_invalid_entity', $response, 404 ); + } + + /** + * Root entities require `manage_options`. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + */ + 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 + */ + 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, and cannot be empty. + * + * @covers ::register_routes + */ + 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 ); + + $empty_kind = $this->dispatch_request( '', 'page' ); + $this->assertErrorResponse( 'rest_invalid_param', $empty_kind, 400 ); + + $empty_name = $this->dispatch_request( 'postType', '' ); + $this->assertErrorResponse( 'rest_invalid_param', $empty_name, 400 ); + } + + /** + * A numeric script module id is returned as a string, like any other. + * + * @covers ::get_items + */ + public function test_get_items_returns_a_numeric_module_id_as_a_string() { + wp_set_current_user( self::$editor_id ); + $this->register_actions( 'postType', 'page', array( array( 'id' => 'feature' ) ), '123' ); + + $script_modules = $this->dispatch_request()->get_data()['script_modules']; + + $this->assertSame( '123', end( $script_modules )['id'] ); + } + + /** + * The response echoes the entity and lists its actions and script modules. + * + * @covers ::get_items + */ + public function test_get_items_returns_entity_and_actions_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['actions'] ); + $this->assertIsArray( $data['script_modules'] ); + } + + /** + * An entity without registered actions returns empty lists, which encode + * as JSON arrays. + * + * @covers ::get_items + */ + public function test_get_items_returns_empty_lists_without_registered_actions() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request( 'customKind', 'customName' ); + $data = $response->get_data(); + + $this->assertSame( array(), $data['actions'] ); + $this->assertSame( array(), $data['script_modules'] ); + + $json = wp_json_encode( rest_get_server()->response_to_data( $response, false ) ); + $this->assertStringContainsString( '"actions":[]', $json ); + $this->assertStringContainsString( '"script_modules":[]', $json ); + } + + /** + * The actions registered by a plugin are exposed as registered, in + * registration order, and each script module lists the actions it applies + * to. + * + * @covers ::get_items + */ + public function test_get_items_exposes_registered_actions_and_their_script_modules() { + $feature = array( + 'id' => 'feature', + 'label' => 'Feature', + 'isPrimary' => true, + 'supportsBulk' => true, + ); + $archive = array( + 'id' => 'archive', + 'label' => 'Archive…', + 'modalHeader' => 'Archive', + 'context' => 'list', + ); + $share = array( + 'id' => 'share', + 'label' => 'Share', + ); + $this->register_actions( 'customKind', 'customName', array( $feature, $archive ), 'plugin/actions' ); + $this->register_actions( 'customKind', 'customName', array( $share ), 'plugin/share' ); + + wp_set_current_user( self::$editor_id ); + $data = $this->dispatch_request( 'customKind', 'customName' )->get_data(); + + $origin = array( 'origin' => array( 'registeredBy' => 'test-plugin' ) ); + $this->assertSame( array( $feature + $origin, $archive + $origin, $share + $origin ), $data['actions'] ); + $this->assertSame( + array( + array( + 'id' => 'plugin/actions', + 'actions' => array( 'feature', 'archive' ), + ), + array( + 'id' => 'plugin/share', + 'actions' => array( 'share' ), + ), + ), + $data['script_modules'] + ); + } + + /** + * Actions registered without a script module have none listed. + * + * @covers ::get_items + */ + public function test_get_items_lists_no_script_module_for_plain_actions() { + $this->register_actions( + 'customKind', + 'customName', + array( + array( + 'id' => 'plain', + 'label' => 'Plain', + ), + ) + ); + + wp_set_current_user( self::$editor_id ); + $data = $this->dispatch_request( 'customKind', 'customName' )->get_data(); + + $this->assertSame( array( 'plain' ), array_column( $data['actions'], 'id' ) ); + $this->assertSame( array(), $data['script_modules'] ); + } + + /** + * The item schema describes the response. + * + * @covers ::get_item_schema + * @covers ::get_action_schema + */ + public function test_get_item_schema() { + $controller = new WP_REST_Entity_Actions_Controller(); + $schema = $controller->get_item_schema(); + + $this->assertSame( array( 'kind', 'name', 'actions', 'script_modules' ), array_keys( $schema['properties'] ) ); + + $action = $schema['properties']['actions']['items']; + $this->assertSame( 'object', $action['type'] ); + $this->assertTrue( $action['additionalProperties'], 'A plugin can register properties of its own.' ); + foreach ( array( 'id', 'origin', 'label', 'isPrimary', 'supportsBulk', 'disabled', 'context', 'modalHeader', 'hideModalHeader', 'modalSize', 'modalFocusOnMount' ) as $property ) { + $this->assertArrayHasKey( $property, $action['properties'], "The `$property` action property should be described." ); + } + foreach ( array( 'callback', 'RenderModal', 'isEligible', 'icon' ) as $property ) { + $this->assertArrayNotHasKey( $property, $action['properties'], "The `$property` action property comes from the script modules." ); + } + + $module = $schema['properties']['script_modules']['items']; + $this->assertSame( array( 'id', 'actions' ), array_keys( $module['properties'] ) ); + } + + /** + * The response of the route validates against its schema. + * + * @covers ::get_items + * @covers ::get_item_schema + */ + public function test_get_items_response_matches_the_schema() { + $this->register_actions( + 'postType', + 'page', + array( + array( + 'id' => 'feature', + 'label' => 'Feature…', + 'modalSize' => 'medium', + 'modalFocusOnMount' => true, + ), + ), + 'plugin/actions' + ); + + wp_set_current_user( self::$editor_id ); + $response = $this->dispatch_request( 'postType', 'page' ); + $schema = ( new WP_REST_Entity_Actions_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 9ee6cc4cbed16..211d0fcd4afeb 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -206,6 +206,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/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-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 70334d225e52d..3caf6ba066213 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -13085,6 +13085,40 @@ mockedApiResponse.Schema = { } ] } + }, + "/wp/v2/entity-actions": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "kind": { + "description": "Entity kind.", + "type": "string", + "minLength": 1, + "required": true + }, + "name": { + "description": "Entity name.", + "type": "string", + "minLength": 1, + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/entity-actions" + } + ] + } } }, "image_sizes": { From 982449e1cde23c3f1066c0f8a7e0b9c80e522bae Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 19 Oct 2026 16:47:45 +0100 Subject: [PATCH 2/3] Tests: Add the ticket to the entity actions tests. See #66343. Co-Authored-By: Claude Opus 5.5 --- tests/phpunit/tests/entity-actions.php | 64 +++++++++++++++++++ .../rest-entity-actions-controller.php | 40 ++++++++++++ 2 files changed, 104 insertions(+) diff --git a/tests/phpunit/tests/entity-actions.php b/tests/phpunit/tests/entity-actions.php index 70bc7cf6fb9ae..8283a81b6b64d 100644 --- a/tests/phpunit/tests/entity-actions.php +++ b/tests/phpunit/tests/entity-actions.php @@ -190,6 +190,8 @@ static function ( $function_name, $message ) use ( &$reported, $method ) { * 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 66343 */ public function test_the_modules_are_added_on_admin_footer() { $this->assertSame( 10, has_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script' ) ); @@ -198,6 +200,8 @@ public function test_the_modules_are_added_on_admin_footer() { /** * A page that registers the editor script without loading it is left * untouched, and the registry is not read. + * + * @ticket 66343 */ public function test_nothing_happens_when_the_editor_script_is_not_enqueued() { $scripts = new WP_Scripts(); @@ -216,6 +220,8 @@ public function test_nothing_happens_when_the_editor_script_is_not_enqueued() { * 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 66343 */ public function test_the_modules_are_added_when_the_editor_script_is_a_dependency() { $this->unregister_all_action_modules(); @@ -234,6 +240,8 @@ public function test_the_modules_are_added_when_the_editor_script_is_a_dependenc /** * A scripts registry without the editor script is left untouched, even * when action script modules are registered. + * + * @ticket 66343 */ public function test_nothing_happens_without_the_editor_script() { $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); @@ -250,6 +258,8 @@ public function test_nothing_happens_without_the_editor_script() { /** * The dependencies of the editor script stay untouched when no entity has * a script module registered. + * + * @ticket 66343 */ public function test_nothing_happens_without_registered_action_modules() { $this->unregister_all_action_modules(); @@ -267,6 +277,8 @@ public function test_nothing_happens_without_registered_action_modules() { /** * The script module of an action becomes a dynamic dependency of the * editor script. + * + * @ticket 66343 */ public function test_action_script_modules_become_dynamic_dependencies_of_the_editor_script() { $this->unregister_all_action_modules(); @@ -293,6 +305,8 @@ public function test_action_script_modules_become_dynamic_dependencies_of_the_ed /** * The dependencies already declared are kept, and the modules are added * once: a second run adds nothing. + * + * @ticket 66343 */ public function test_declared_dependencies_are_kept_and_modules_are_added_once() { $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/feature' ); @@ -321,6 +335,8 @@ public function test_declared_dependencies_are_kept_and_modules_are_added_once() /** * A module registered for several entities is declared once. + * + * @ticket 66343 */ public function test_a_module_shared_by_several_entities_is_declared_once() { $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), 'plugin/actions' ); @@ -340,6 +356,8 @@ public function test_a_module_shared_by_several_entities_is_declared_once() { /** * The script modules of the actions reach the import map of a page that * loads the editor script. + * + * @ticket 66343 */ public function test_action_script_modules_reach_the_import_map() { global $wp_scripts; @@ -372,6 +390,8 @@ public function test_action_script_modules_reach_the_import_map() { /** * Reading the registry fires `wp_entity_actions_api_init` once, whichever * getter is read first and however many times it is read. + * + * @ticket 66343 */ public function test_reading_the_registry_fires_the_action_once() { self::reset_registry(); @@ -391,6 +411,8 @@ public function test_reading_the_registry_fires_the_action_once() { /** * Firing the action again once the registry has been read does not reopen * it: the registrations made then are refused. + * + * @ticket 66343 */ public function test_firing_the_action_again_does_not_reopen_the_registry() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -412,6 +434,8 @@ function () { * it can remove a default action, or replace it by unregistering it and * registering it again with the script module of the default, which keeps * its JavaScript parts. + * + * @ticket 66343 */ public function test_a_callback_at_the_default_priority_alters_the_defaults() { $callback = static function () { @@ -456,6 +480,8 @@ public function test_a_callback_at_the_default_priority_alters_the_defaults() { * The origin an action is registered with is stored on its definition, * over any `origin` the definition sets; the default actions come from * `core`. + * + * @ticket 66343 */ public function test_actions_carry_the_origin_they_are_registered_with() { $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) + array( 'origin' => 'spoofed' ) ) ); @@ -469,6 +495,8 @@ public function test_actions_carry_the_origin_they_are_registered_with() { * An action can only be registered once: an action with the id of a * registered action is skipped and leaves it untouched, and the rest of * the actions of the call are registered. + * + * @ticket 66343 */ public function test_registering_a_registered_action_is_skipped() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -501,6 +529,8 @@ public function test_registering_a_registered_action_is_skipped() { * A `context` or `modalSize` DataViews does not support is reported, and * registered anyway. A property only a script module can provide is * reported and removed. The other actions of the call are not reported. + * + * @ticket 66343 */ public function test_registering_an_action_with_invalid_values_is_reported() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -541,6 +571,8 @@ public function test_registering_an_action_with_invalid_values_is_reported() { * An id that appears more than once in a call is registered once, with * its first definition, and the rest of the actions of the call are * registered. + * + * @ticket 66343 */ public function test_registering_the_same_action_twice_keeps_the_first_definition() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -570,6 +602,8 @@ public function test_registering_the_same_action_twice_keeps_the_first_definitio /** * The actions of an entity keep their registration order: the order the * client shows them in. + * + * @ticket 66343 */ public function test_actions_keep_their_registration_order() { $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ), $this->action( 'archive' ) ), 'plugin/actions' ); @@ -594,6 +628,8 @@ public function test_actions_keep_their_registration_order() { * 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 actions and * script modules do not mix. + * + * @ticket 66343 */ public function test_entities_whose_kind_and_name_join_alike_are_kept_apart() { $result = array(); @@ -620,6 +656,8 @@ function () use ( &$result ) { * 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. * A module left without actions is forgotten. + * + * @ticket 66343 */ public function test_unregistering_returns_the_unregistered_actions() { $unregistered = array(); @@ -661,6 +699,8 @@ function () use ( &$unregistered, &$modules ) { * A definition that is not an array with a non-empty string `id` is * reported by its position and skipped, like a duplicated action: the * rest of the actions of the call are registered. + * + * @ticket 66343 */ public function test_an_invalid_definition_is_skipped() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -691,6 +731,8 @@ public function test_an_invalid_definition_is_skipped() { /** * A call whose definitions are all invalid registers nothing, and its * notice does not mention the rest of the call. + * + * @ticket 66343 */ public function test_a_call_with_only_invalid_definitions_registers_nothing() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -704,6 +746,8 @@ public function test_a_call_with_only_invalid_definitions_registers_nothing() { /** * An origin that is not a non-empty string is refused. + * + * @ticket 66343 */ public function test_registering_with_an_invalid_origin_is_refused() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -721,6 +765,8 @@ static function () use ( &$registered ) { /** * Actions that are not a list, such as actions keyed by id, are refused, * and so is an empty list or a script module that is not an id. + * + * @ticket 66343 */ public function test_registering_actions_that_are_not_a_list_is_refused() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -751,6 +797,8 @@ static function () use ( &$results ) { * 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 66343 */ public function test_unregistering_with_an_invalid_entity_is_refused() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); @@ -775,6 +823,8 @@ static function () use ( &$results ) { /** * An id that is not a non-empty string is reported and skipped: the * other ids of the call are unregistered. + * + * @ticket 66343 */ public function test_unregistering_with_an_invalid_id_skips_it() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); @@ -794,6 +844,8 @@ static function () use ( &$unregistered ) { /** * A call whose ids are all invalid unregisters nothing, and its notice * does not mention the rest of the call. + * + * @ticket 66343 */ public function test_unregistering_only_invalid_ids_unregisters_nothing() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); @@ -814,6 +866,8 @@ static function () use ( &$unregistered ) { /** * Reading the actions or the script modules of an entity whose kind or * name is not a non-empty string is reported, and returns nothing. + * + * @ticket 66343 */ public function test_reading_an_invalid_entity_is_reported() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::get_registered' ); @@ -825,6 +879,8 @@ public function test_reading_an_invalid_entity_is_reported() { /** * A numeric script module id is read back as a string, like any other. + * + * @ticket 66343 */ public function test_a_numeric_module_id_is_read_as_a_string() { $this->register_actions( 'postType', 'page', array( $this->action( 'feature' ) ), '123' ); @@ -837,6 +893,8 @@ public function test_a_numeric_module_id_is_read_as_a_string() { * registry is left untouched. Before the action a registration would * keep the defaults from being registered; after it the actions have been * read and the import map of the editor script built from them. + * + * @ticket 66343 */ public function test_registering_outside_the_action_is_refused() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::register' ); @@ -854,6 +912,8 @@ public function test_registering_outside_the_action_is_refused() { * reading the registry, so a stray call, from an `init` callback say, * neither drops a default action nor fires the action early against * post types still being registered. + * + * @ticket 66343 */ public function test_unregistering_outside_the_action_is_refused() { $this->setExpectedIncorrectUsage( 'WP_Entity_Actions_Registry::unregister' ); @@ -870,6 +930,8 @@ public function test_unregistering_outside_the_action_is_refused() { * 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 66343 */ public function test_reading_before_init_does_not_fire_the_action() { global $wp_actions; @@ -899,6 +961,8 @@ public function test_reading_before_init_does_not_fire_the_action() { * 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 66343 */ public function test_reading_during_init_does_not_fire_the_action() { global $wp_current_filter; diff --git a/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php b/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php index a543c6baca70b..c67107147493a 100644 --- a/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php @@ -133,6 +133,8 @@ private function dispatch_request( $kind = 'postType', $name = 'page' ) { * The route is registered once, and served by the controller. * * @covers ::register_routes + * + * @ticket 66343 */ public function test_register_routes() { $routes = rest_get_server()->get_routes(); @@ -148,6 +150,8 @@ public function test_register_routes() { * * @covers ::register_routes * @covers ::is_route_of_entity_items + * + * @ticket 66343 */ public function test_the_route_of_a_taxonomy_with_the_same_base_is_kept() { global $wp_rest_server; @@ -175,6 +179,8 @@ public function test_the_route_of_a_taxonomy_with_the_same_base_is_kept() { * * @covers ::get_items_permissions_check * @covers ::get_items + * + * @ticket 66343 */ public function test_get_items_allows_users_with_edit_posts() { wp_set_current_user( self::$editor_id ); @@ -188,6 +194,8 @@ public function test_get_items_allows_users_with_edit_posts() { * Subscribers (without `edit_posts`) are forbidden. * * @covers ::get_items_permissions_check + * + * @ticket 66343 */ public function test_get_items_forbids_users_without_edit_posts() { wp_set_current_user( self::$subscriber_id ); @@ -201,6 +209,8 @@ public function test_get_items_forbids_users_without_edit_posts() { * Logged-out users are unauthorized. * * @covers ::get_items_permissions_check + * + * @ticket 66343 */ public function test_get_items_requires_authentication() { wp_set_current_user( 0 ); @@ -219,6 +229,8 @@ public function test_get_items_requires_authentication() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_uses_post_type_specific_capability() { wp_set_current_user( self::$editor_id ); @@ -240,6 +252,8 @@ public function test_get_items_uses_post_type_specific_capability() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_unknown_post_type_is_not_found() { wp_set_current_user( self::$admin_id ); @@ -255,6 +269,8 @@ public function test_get_items_unknown_post_type_is_not_found() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_post_type_hidden_from_rest_is_not_found() { register_post_type( 'hidden_post_type', array( 'show_in_rest' => false ) ); @@ -285,6 +301,8 @@ public function test_get_items_post_type_hidden_from_rest_is_not_found() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_uses_taxonomy_capability() { wp_set_current_user( self::$editor_id ); @@ -306,6 +324,8 @@ public function test_get_items_uses_taxonomy_capability() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_unknown_taxonomy_is_not_found() { wp_set_current_user( self::$admin_id ); @@ -320,6 +340,8 @@ public function test_get_items_unknown_taxonomy_is_not_found() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_root_requires_manage_options() { wp_set_current_user( self::$editor_id ); @@ -341,6 +363,8 @@ public function test_get_items_root_requires_manage_options() { * * @covers ::get_items_permissions_check * @covers ::get_required_capability + * + * @ticket 66343 */ public function test_get_items_unknown_kind_falls_back_to_edit_posts() { wp_set_current_user( self::$editor_id ); @@ -361,6 +385,8 @@ public function test_get_items_unknown_kind_falls_back_to_edit_posts() { * Both `kind` and `name` are required, and cannot be empty. * * @covers ::register_routes + * + * @ticket 66343 */ public function test_get_items_requires_kind_and_name() { wp_set_current_user( self::$admin_id ); @@ -382,6 +408,8 @@ public function test_get_items_requires_kind_and_name() { * A numeric script module id is returned as a string, like any other. * * @covers ::get_items + * + * @ticket 66343 */ public function test_get_items_returns_a_numeric_module_id_as_a_string() { wp_set_current_user( self::$editor_id ); @@ -396,6 +424,8 @@ public function test_get_items_returns_a_numeric_module_id_as_a_string() { * The response echoes the entity and lists its actions and script modules. * * @covers ::get_items + * + * @ticket 66343 */ public function test_get_items_returns_entity_and_actions_shape() { wp_set_current_user( self::$editor_id ); @@ -415,6 +445,8 @@ public function test_get_items_returns_entity_and_actions_shape() { * as JSON arrays. * * @covers ::get_items + * + * @ticket 66343 */ public function test_get_items_returns_empty_lists_without_registered_actions() { wp_set_current_user( self::$editor_id ); @@ -436,6 +468,8 @@ public function test_get_items_returns_empty_lists_without_registered_actions() * to. * * @covers ::get_items + * + * @ticket 66343 */ public function test_get_items_exposes_registered_actions_and_their_script_modules() { $feature = array( @@ -481,6 +515,8 @@ public function test_get_items_exposes_registered_actions_and_their_script_modul * Actions registered without a script module have none listed. * * @covers ::get_items + * + * @ticket 66343 */ public function test_get_items_lists_no_script_module_for_plain_actions() { $this->register_actions( @@ -506,6 +542,8 @@ public function test_get_items_lists_no_script_module_for_plain_actions() { * * @covers ::get_item_schema * @covers ::get_action_schema + * + * @ticket 66343 */ public function test_get_item_schema() { $controller = new WP_REST_Entity_Actions_Controller(); @@ -532,6 +570,8 @@ public function test_get_item_schema() { * * @covers ::get_items * @covers ::get_item_schema + * + * @ticket 66343 */ public function test_get_items_response_matches_the_schema() { $this->register_actions( From 58adad61aca1d66ca30b180b6e47d765b399e0ce Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 19 Oct 2026 16:53:31 +0100 Subject: [PATCH 3/3] Editor: Reuse the supported values in the entity actions schema. The `context` and `modalSize` enums of the action schema now read WP_Entity_Actions_Registry::SUPPORTED_VALUES instead of repeating the values. --- .../endpoints/class-wp-rest-entity-actions-controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php index b2f96e8418c1a..d72cab8dc26c7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-entity-actions-controller.php @@ -328,7 +328,7 @@ protected function get_action_schema() { 'context' => array( 'description' => __( 'The context the action is limited to, such as `list` for the lists of items. Without it, the action shows everywhere.' ), 'type' => 'string', - 'enum' => array( 'list', 'single' ), + 'enum' => WP_Entity_Actions_Registry::SUPPORTED_VALUES['context'], ), 'modalHeader' => array( 'description' => __( 'The header of the modal of the action.' ), @@ -341,7 +341,7 @@ protected function get_action_schema() { 'modalSize' => array( 'description' => __( 'The size of the modal of the action.' ), 'type' => 'string', - 'enum' => array( 'small', 'medium', 'large', 'fill' ), + 'enum' => WP_Entity_Actions_Registry::SUPPORTED_VALUES['modalSize'], ), 'modalFocusOnMount' => array( 'description' => __( 'Where the focus goes when the modal of the action opens.' ),