Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/wp-includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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.
*
Expand All @@ -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()
Expand Down
48 changes: 31 additions & 17 deletions src/wp-includes/abilities-api/class-wp-abilities-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
*
Expand All @@ -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.
Expand Down Expand Up @@ -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<string, mixed> $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.
Expand All @@ -138,21 +142,31 @@ public function register( string $name, array $args ): ?WP_Ability {
*/
$args = apply_filters( 'wp_register_ability_args', $args, $name );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we set the uncategorized category if a category was not passed before executing this filter?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put the fallback after the filter on purpose. This way it also covers the case where a filter removes the category, and filters can still tell when no category was passed. The filter docblock documents the default, so callbacks know what a missing key means.


// Validate ability category exists if provided (will be validated as required in WP_Ability).
if ( isset( $args['category'] ) ) {
Comment thread
gziolo marked this conversation as resolved.
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.
Expand Down
9 changes: 9 additions & 0 deletions src/wp-includes/abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.' ),
)
);
}

/**
Expand Down
62 changes: 62 additions & 0 deletions tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<mixed>> 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
*
Comment thread
gziolo marked this conversation as resolved.
* @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.
*
Expand Down
39 changes: 39 additions & 0 deletions tests/phpunit/tests/abilities-api/wpRegisterAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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.
*
Expand Down
13 changes: 13 additions & 0 deletions tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading