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..d72cab8dc26c7 --- /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' => WP_Entity_Actions_Registry::SUPPORTED_VALUES['context'], + ), + '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' => WP_Entity_Actions_Registry::SUPPORTED_VALUES['modalSize'], + ), + '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..8283a81b6b64d --- /dev/null +++ b/tests/phpunit/tests/entity-actions.php @@ -0,0 +1,989 @@ + '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. + * + * @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' ) ); + } + + /** + * 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(); + $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`. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @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' ); + + $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. + * + * @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' ); + $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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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`. + * + * @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' ) ) ); + + $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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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. + * + * @ticket 66343 + */ + 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..c67107147493a --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-entity-actions-controller.php @@ -0,0 +1,597 @@ +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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + public function test_get_items_allows_users_with_edit_posts() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_request(); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Subscribers (without `edit_posts`) are forbidden. + * + * @covers ::get_items_permissions_check + * + * @ticket 66343 + */ + public function test_get_items_forbids_users_without_edit_posts() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_request(); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * Logged-out users are unauthorized. + * + * @covers ::get_items_permissions_check + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + public function test_get_items_uses_post_type_specific_capability() { + wp_set_current_user( self::$editor_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'postType', 'wp_template_part' ), + 403 + ); + + wp_set_current_user( self::$admin_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'postType', 'wp_template_part' )->get_status() + ); + } + + /** + * A post type that is not registered is not found. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66343 + */ + 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 + * + * @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 ) ); + 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 + * + * @ticket 66343 + */ + public function test_get_items_uses_taxonomy_capability() { + wp_set_current_user( self::$editor_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'taxonomy', 'category' )->get_status() + ); + + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'taxonomy', 'category' ), + 403 + ); + } + + /** + * A taxonomy that is not registered is not found. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + public function test_get_items_root_requires_manage_options() { + wp_set_current_user( self::$editor_id ); + $this->assertErrorResponse( + 'rest_cannot_read', + $this->dispatch_request( 'root', 'site' ), + 403 + ); + + wp_set_current_user( self::$admin_id ); + $this->assertSame( + 200, + $this->dispatch_request( 'root', 'site' )->get_status() + ); + } + + /** + * An unknown kind falls back to `edit_posts`. + * + * @covers ::get_items_permissions_check + * @covers ::get_required_capability + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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 + * + * @ticket 66343 + */ + 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": {