diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 37cb1a28072f2..92e13f1b489fc 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -128,6 +128,9 @@ * } * add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' ); * + * On failure, this function returns `null` and calls `_doing_it_wrong()` with the reason. + * By default, the resulting notice is displayed when `WP_DEBUG` is enabled. + * * ### Naming Conventions * * Ability names must follow these rules: @@ -138,8 +141,9 @@ * * ### Categories * - * Abilities must be organized into categories. Ability categories provide better - * discoverability and must be registered before the abilities that reference them: + * Abilities can be organized into categories. If no category is provided, the ability is + * assigned to the built-in `uncategorized` category. Custom categories must be registered + * before the abilities that reference them: * * function my_plugin_register_categories(): void { * wp_register_ability_category( @@ -228,6 +232,7 @@ * ), * * @since 6.9.0 + * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. * * @see WP_Abilities_Registry::register() * @see wp_register_ability_category() @@ -242,9 +247,9 @@ * @type string $label Required. The human-readable label for the ability. * @type string $description Required. A detailed description of what the ability does * and when it should be used. - * @type string $category Required. The ability category slug this ability belongs to. - * The ability category must be registered via `wp_register_ability_category()` - * before registering the ability. + * @type string $category Optional. The ability category slug this ability belongs to. + * Defaults to `uncategorized`. Custom categories must be registered + * via `wp_register_ability_category()` before registering the ability. * @type callable $execute_callback Required. A callback function to execute when the ability is invoked. * Receives optional mixed input data and must return either a result * value (any type) or a `WP_Error` object on failure. @@ -607,8 +612,10 @@ function _wp_get_abilities_match_meta( array $meta, array $conditions ): bool { * Registers a new ability category. * * Ability categories provide a way to organize and group related abilities for better - * discoverability and management. Ability categories must be registered before abilities - * that reference them. + * discoverability and management. Custom categories must be registered before abilities + * that reference them. Abilities that omit a category are assigned to the built-in + * `uncategorized` category, which is intended as an escape hatch for simple or transitional + * registrations. * * Ability categories must be registered on the `wp_abilities_api_categories_init` action hook. * @@ -625,6 +632,9 @@ function _wp_get_abilities_match_meta( array $meta, array $conditions ): bool { * } * add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_categories' ); * + * On failure, this function returns `null` and calls `_doing_it_wrong()` with the reason. + * By default, the resulting notice is displayed when `WP_DEBUG` is enabled. + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::register() diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 7d1f26d7690ca..cc795a1a8a430 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -40,6 +40,7 @@ final class WP_Abilities_Registry { * Do not use this method directly. Instead, use the `wp_register_ability()` function. * * @since 6.9.0 + * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. * * @see wp_register_ability() * @@ -51,7 +52,8 @@ final class WP_Abilities_Registry { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The ability category slug this ability belongs to. + * @type string $category Optional. The ability category slug this ability belongs to. + * Defaults to `uncategorized`. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -108,13 +110,15 @@ public function register( string $name, array $args ): ?WP_Ability { * Filters the ability arguments before they are validated and used to instantiate the ability. * * @since 6.9.0 + * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. * * @param array $args { * An associative array of arguments for the ability. * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The ability category slug this ability belongs to. + * @type string $category Optional. The ability category slug this ability belongs to. + * Defaults to `uncategorized`. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -138,21 +142,31 @@ public function register( string $name, array $args ): ?WP_Ability { */ $args = apply_filters( 'wp_register_ability_args', $args, $name ); - // Validate ability category exists if provided (will be validated as required in WP_Ability). - if ( isset( $args['category'] ) ) { - if ( ! wp_has_ability_category( $args['category'] ) ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %1$s: ability category slug, %2$s: ability name */ - __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ), - esc_html( $args['category'] ), - esc_html( $name ) - ), - '6.9.0' - ); - return null; - } + if ( ! array_key_exists( 'category', $args ) ) { + $args['category'] = 'uncategorized'; + } + + if ( ! is_string( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability category must be a string.' ), + '6.9.0' + ); + return null; + } + + if ( ! wp_has_ability_category( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %1$s: ability category slug, %2$s: ability name */ + __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ), + esc_html( $args['category'] ), + esc_html( $name ) + ), + '6.9.0' + ); + return null; } // The class is only used to instantiate the ability, and is not a property of the ability itself. diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 236b99836a3b9..1386c0deb7741 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -13,6 +13,7 @@ * Registers the core ability categories. * * @since 6.9.0 + * @since 7.2.0 Added the `uncategorized` category. */ function wp_register_core_ability_categories(): void { wp_register_ability_category( @@ -30,6 +31,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'uncategorized', + array( + 'label' => __( 'Uncategorized' ), + 'description' => __( 'Abilities that have not been assigned to a specific category.' ), + ) + ); } /** diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index f796ca668acc1..613fb7afb0474 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -224,6 +224,68 @@ public function test_register_ability_nonexistent_category(): void { $this->assertNull( $result, 'Should return null when category does not exist.' ); } + /** + * Tests that an invalid category type is rejected before the category lookup. + * + * @ticket 65569 + * + * @dataProvider data_invalid_category_types + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + * + * @param mixed $category Invalid category value. + */ + public function test_register_ability_rejects_invalid_category_type( $category ): void { + $args = self::$test_ability_args; + $args['category'] = $category; + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result ); + $this->assertStringContainsString( + 'Ability category must be a string.', + $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] + ); + } + + /** + * Data provider for invalid category types. + * + * @return array> Test cases. + */ + public static function data_invalid_category_types(): array { + return array( + 'null' => array( null ), + 'boolean' => array( false ), + 'integer' => array( 1 ), + 'array' => array( array() ), + ); + } + + /** + * Tests that an empty category is rejected rather than replaced by the default. + * + * @ticket 65569 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_rejects_empty_category(): void { + $args = self::$test_ability_args; + $args['category'] = ''; + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result ); + $this->assertStringContainsString( + 'Ability category "" is not registered.', + $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] + ); + } + /** * Should reject ability registration without an execute callback. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 348b1306ba61e..7243171da1da4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -100,6 +100,9 @@ public function tear_down(): void { // Clean up registered test ability category. wp_unregister_ability_category( 'math' ); + if ( wp_has_ability_category( 'uncategorized' ) ) { + wp_unregister_ability_category( 'uncategorized' ); + } parent::tear_down(); } @@ -222,6 +225,42 @@ public function test_register_valid_ability(): void { ); } + /** + * @ticket 65569 + */ + public function test_register_ability_without_category_uses_uncategorized_category(): void { + global $wp_current_filter; + + $this->simulate_doing_wp_abilities_init_action(); + + $wp_current_filter[] = 'wp_abilities_api_categories_init'; + wp_register_ability_category( + 'uncategorized', + array( + 'label' => 'Uncategorized', + 'description' => 'Abilities that have not been assigned to a specific category.', + ) + ); + array_pop( $wp_current_filter ); + + $ability = wp_register_ability( + 'test/without-category', + array( + 'label' => 'Test ability without category', + 'description' => 'Test ability description.', + 'input_schema' => array(), + 'output_schema' => array(), + 'permission_callback' => '__return_true', + 'execute_callback' => static function (): array { + return array( 'success' => true ); + }, + ) + ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'uncategorized', $ability->get_category() ); + } + /** * Tests executing an ability with no permissions. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 9f15250022c7a..85a4e5c1e82e4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -53,6 +53,19 @@ public static function tear_down_after_class(): void { parent::tear_down_after_class(); } + /** + * Tests that the uncategorized fallback category is registered by core. + * + * @ticket 65569 + */ + public function test_uncategorized_category_is_registered(): void { + $category = wp_get_ability_category( 'uncategorized' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $category ); + $this->assertSame( 'Uncategorized', $category->get_label() ); + $this->assertSame( 'Abilities that have not been assigned to a specific category.', $category->get_description() ); + } + /** * Tests that the `core/get-site-info` ability is registered with the expected schema. * @ticket 64146