From c9a39db049ae68d57b5c98572acb11da1785aa6b Mon Sep 17 00:00:00 2001 From: Em Date: Wed, 15 Oct 2025 07:24:01 -0400 Subject: [PATCH 1/6] add: category listing endpoints for the REST API --- docs/5.rest-api.md | 2 +- ...lass-wp-rest-abilities-list-controller.php | 2 +- .../wpRestAbilitiesListController.php | 22 +++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/5.rest-api.md b/docs/5.rest-api.md index bbadba3b..6f0fe138 100644 --- a/docs/5.rest-api.md +++ b/docs/5.rest-api.md @@ -4,7 +4,7 @@ The WordPress Abilities API provides REST endpoints that allow external systems ## User access -Access to all Abilities REST API endpoints requires an authenticated user (see the [Authentication](#authentication) section). Access to execute individual Abilities is restricted based on the `permission_callback()` of the Ability. +Access to all Abilities REST API endpoints requires an authenticated user with administrator privileges (`manage_options` capability). This includes listing abilities, retrieving individual abilities, listing categories, and executing abilities. Access to execute individual Abilities is further restricted based on the `permission_callback()` of the Ability. ## Controlling REST API Exposure diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 54e393f4..45597de4 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -189,7 +189,7 @@ public function get_item( $request ) { * @return bool True if the request has read access. */ public function get_permissions_check( $request ) { - return current_user_can( 'read' ); + return current_user_can( 'manage_options' ); } /** diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 24093c46..5b30b7e5 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -29,10 +29,10 @@ class Tests_REST_API_WpRestAbilitiesListController extends WP_UnitTestCase { public static function set_up_before_class(): void { parent::set_up_before_class(); - // Create a test user with read capabilities + // Create a test user with admin capabilities self::$user_id = self::factory()->user->create( array( - 'role' => 'subscriber', + 'role' => 'administrator', ) ); } @@ -323,9 +323,9 @@ public function test_get_item_not_show_in_rest(): void { } /** - * Test permission check for listing abilities. + * Test permission check for listing abilities requires authentication. */ - public function test_get_items_permission_denied(): void { + public function test_get_items_permission_denied_unauthenticated(): void { // Test with non-logged-in user wp_set_current_user( 0 ); @@ -335,6 +335,20 @@ public function test_get_items_permission_denied(): void { $this->assertEquals( 401, $response->get_status() ); } + /** + * Test permission check for listing abilities requires admin. + */ + public function test_get_items_permission_denied_non_admin(): void { + // Create and use a subscriber user (non-admin) + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } + /** * Test pagination headers. */ From 05f4ccac07d41d604a62b5fb5494d9201c016285 Mon Sep 17 00:00:00 2001 From: Em Date: Wed, 15 Oct 2025 08:31:15 -0400 Subject: [PATCH 2/6] add: client support for category listing and valdating before registering an ability --- packages/client/README.md | 53 +++- packages/client/src/__tests__/api.test.ts | 116 ++++++++- packages/client/src/api.ts | 45 +++- packages/client/src/index.ts | 4 + .../src/store/__tests__/actions.test.ts | 162 +++++++++--- .../src/store/__tests__/resolvers.test.ts | 235 +++++++++++++++++- .../src/store/__tests__/selectors.test.ts | 158 +++++++++++- packages/client/src/store/actions.ts | 47 +++- packages/client/src/store/constants.ts | 2 + packages/client/src/store/index.ts | 17 +- packages/client/src/store/reducer.ts | 60 ++++- packages/client/src/store/resolvers.ts | 59 ++++- packages/client/src/store/selectors.ts | 38 ++- packages/client/src/types.ts | 38 +++ 14 files changed, 969 insertions(+), 65 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 4588d2e8..3528bc9d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -100,14 +100,39 @@ if ( ability ) { } ``` -#### `registerAbility( ability: Ability ): void` +#### `getAbilityCategories(): Promise` + +Returns all registered ability categories. Categories are used to organize abilities into logical groups. + +```javascript +const categories = await getAbilityCategories(); +console.log( `Found ${ categories.length } categories` ); + +categories.forEach( ( category ) => { + console.log( `${ category.label }: ${ category.description }` ); +} ); +``` + +#### `getAbilityCategory( slug: string ): Promise` + +Returns a specific ability category by slug, or null if not found. + +```javascript +const category = await getAbilityCategory( 'data-retrieval' ); +if ( category ) { + console.log( `Found category: ${ category.label }` ); + console.log( `Description: ${ category.description }` ); +} +``` + +#### `registerAbility( ability: Ability ): Promise` Registers a client-side ability. Client abilities are executed locally in the browser and must include a callback function and a valid category. ```javascript import { registerAbility } from '@wordpress/abilities'; -registerAbility( { +await registerAbility( { name: 'my-plugin/navigate', label: 'Navigate to URL', description: 'Navigates to a URL within WordPress admin', @@ -152,6 +177,8 @@ When using with `@wordpress/data`: - `getAbilities( args: AbilitiesQueryArgs = {} )` - Returns all abilities from the store, optionally filtered by query arguments - `getAbility( name: string )` - Returns a specific ability from the store +- `getAbilityCategories()` - Returns all categories from the store +- `getAbilityCategory( slug: string )` - Returns a specific category from the store ```javascript import { useSelect } from '@wordpress/data'; @@ -164,6 +191,12 @@ function MyComponent() { [] ); + // Get all categories + const categories = useSelect( + ( select ) => select( abilitiesStore ).getAbilityCategories(), + [] + ); + // Get abilities in a specific category const dataAbilities = useSelect( ( select ) => @@ -171,10 +204,24 @@ function MyComponent() { [] ); + // Get a specific category + const dataCategory = useSelect( + ( select ) => select( abilitiesStore ).getAbilityCategory( 'data-retrieval' ), + [] + ); + return (

All Abilities ({ allAbilities.length })

-

Data Retrieval Abilities

+

Categories ({ categories.length })

+
    + { categories.map( ( category ) => ( +
  • + { category.label }: { category.description } +
  • + ) ) } +
+

{ dataCategory?.label } Abilities

    { dataAbilities.map( ( ability ) => (
  • { ability.label }
  • diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index 9a0c874b..d99c3bb8 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -14,12 +14,14 @@ import apiFetch from '@wordpress/api-fetch'; import { getAbilities, getAbility, + getAbilityCategories, + getAbilityCategory, registerAbility, unregisterAbility, executeAbility, } from '../api'; import { store } from '../store'; -import type { Ability } from '../types'; +import type { Ability, AbilityCategory } from '../types'; // Mock WordPress dependencies jest.mock( '@wordpress/data', () => ( { @@ -104,7 +106,9 @@ describe( 'API functions', () => { const result = await getAbilities( { category: 'data-retrieval' } ); expect( resolveSelect ).toHaveBeenCalledWith( store ); - expect( mockGetAbilities ).toHaveBeenCalledWith( { category: 'data-retrieval' } ); + expect( mockGetAbilities ).toHaveBeenCalledWith( { + category: 'data-retrieval', + } ); expect( result ).toEqual( mockAbilities ); } ); } ); @@ -502,4 +506,112 @@ describe( 'API functions', () => { ).rejects.toThrow( 'invalid output' ); } ); } ); + + describe( 'getAbilityCategories', () => { + it( 'should resolve and return all categories from the store', async () => { + const mockCategories: AbilityCategory[] = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + }, + ]; + + const mockGetAbilityCategories = jest + .fn() + .mockResolvedValue( mockCategories ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: mockGetAbilityCategories, + } ); + + const result = await getAbilityCategories(); + + expect( resolveSelect ).toHaveBeenCalledWith( store ); + expect( mockGetAbilityCategories ).toHaveBeenCalled(); + expect( result ).toEqual( mockCategories ); + } ); + + it( 'should return empty array when no categories exist', async () => { + const mockGetAbilityCategories = jest + .fn() + .mockResolvedValue( [] ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: mockGetAbilityCategories, + } ); + + const result = await getAbilityCategories(); + + expect( result ).toEqual( [] ); + } ); + } ); + + describe( 'getAbilityCategory', () => { + it( 'should return a specific category by slug', async () => { + const mockCategory: AbilityCategory = { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }; + + const mockGetAbilityCategory = jest + .fn() + .mockResolvedValue( mockCategory ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategory: mockGetAbilityCategory, + } ); + + const result = await getAbilityCategory( 'data-retrieval' ); + + expect( resolveSelect ).toHaveBeenCalledWith( store ); + expect( mockGetAbilityCategory ).toHaveBeenCalledWith( + 'data-retrieval' + ); + expect( result ).toEqual( mockCategory ); + } ); + + it( 'should return null if category not found', async () => { + const mockGetAbilityCategory = jest + .fn() + .mockResolvedValue( null ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategory: mockGetAbilityCategory, + } ); + + const result = await getAbilityCategory( 'non-existent' ); + + expect( mockGetAbilityCategory ).toHaveBeenCalledWith( + 'non-existent' + ); + expect( result ).toBeNull(); + } ); + + it( 'should handle categories with meta', async () => { + const mockCategory: AbilityCategory = { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + meta: { + priority: 'high', + }, + }; + + const mockGetAbilityCategory = jest + .fn() + .mockResolvedValue( mockCategory ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategory: mockGetAbilityCategory, + } ); + + const result = await getAbilityCategory( 'user-management' ); + + expect( result ).toEqual( mockCategory ); + expect( result?.meta ).toBeDefined(); + expect( result?.meta?.priority ).toBe( 'high' ); + } ); + } ); } ); diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 2032fdf3..c8d73709 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -10,7 +10,13 @@ import { sprintf } from '@wordpress/i18n'; * Internal dependencies */ import { store } from './store'; -import type { Ability, AbilitiesQueryArgs, AbilityInput, AbilityOutput } from './types'; +import type { + Ability, + AbilityCategory, + AbilitiesQueryArgs, + AbilityInput, + AbilityOutput, +} from './types'; import { validateValueFromSchema } from './validation'; /** @@ -19,7 +25,9 @@ import { validateValueFromSchema } from './validation'; * @param args Optional query arguments to filter. Defaults to empty object. * @return Promise resolving to array of abilities. */ -export async function getAbilities( args: AbilitiesQueryArgs = {} ): Promise< Ability[] > { +export async function getAbilities( + args: AbilitiesQueryArgs = {} +): Promise< Ability[] > { return await resolveSelect( store ).getAbilities( args ); } @@ -33,6 +41,27 @@ export async function getAbility( name: string ): Promise< Ability | null > { return await resolveSelect( store ).getAbility( name ); } +/** + * Get all available ability categories. + * + * @return Promise resolving to array of categories. + */ +export async function getAbilityCategories(): Promise< AbilityCategory[] > { + return await resolveSelect( store ).getAbilityCategories(); +} + +/** + * Get a specific ability category by slug. + * + * @param slug The category slug. + * @return Promise resolving to the category or null if not found. + */ +export async function getAbilityCategory( + slug: string +): Promise< AbilityCategory | null > { + return await resolveSelect( store ).getAbilityCategory( slug ); +} + /** * Register a client-side ability. * @@ -40,15 +69,21 @@ export async function getAbility( name: string ): Promise< Ability | null > { * a callback function. The ability will be validated by the store action, * and an error will be thrown if validation fails. * + * Categories will be automatically fetched from the REST API if they + * haven't been loaded yet, so you don't need to call getAbilityCategories() + * before registering abilities. + * * @param ability The ability definition including callback. + * @return Promise that resolves when registration is complete. * @throws {Error} If the ability fails validation. * * @example * ```js - * registerAbility({ + * await registerAbility({ * name: 'my-plugin/navigate', * label: 'Navigate to URL', * description: 'Navigates to a URL within WordPress admin', + * category: 'navigation', * input_schema: { * type: 'object', * properties: { @@ -63,8 +98,8 @@ export async function getAbility( name: string ): Promise< Ability | null > { * }); * ``` */ -export function registerAbility( ability: Ability ): void { - dispatch( store ).registerAbility( ability ); +export async function registerAbility( ability: Ability ): Promise< void > { + await dispatch( store ).registerAbility( ability ); } /** diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0fec39c4..7b70c4b3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -31,6 +31,8 @@ declare global { export { getAbilities, getAbility, + getAbilityCategories, + getAbilityCategory, executeAbility, registerAbility, unregisterAbility, @@ -61,7 +63,9 @@ export { store } from './store'; */ export type { Ability, + AbilityCategory, AbilitiesState, + AbilitiesQueryArgs, AbilityCallback, PermissionCallback, AbilityInput, diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index c2fe76e2..5e5a2ab0 100644 --- a/packages/client/src/store/__tests__/actions.test.ts +++ b/packages/client/src/store/__tests__/actions.test.ts @@ -66,11 +66,38 @@ describe( 'Store Actions', () => { jest.clearAllMocks(); mockSelect = { getAbility: jest.fn().mockReturnValue( null ), + getAbilityCategories: jest.fn().mockReturnValue( [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + ] ), + getAbilityCategory: jest.fn().mockImplementation( ( slug ) => { + const categories: Record< string, any > = { + 'test-category': { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + 'data-retrieval': { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + }; + return categories[ slug ] || null; + } ), }; mockDispatch = jest.fn(); } ); - it( 'should register a valid client ability', () => { + it( 'should register a valid client ability', async () => { const ability: Ability = { name: 'test/ability', label: 'Test Ability', @@ -92,7 +119,7 @@ describe( 'Store Actions', () => { }; const action = registerAbility( ability ); - action( { select: mockSelect, dispatch: mockDispatch } ); + await action( { select: mockSelect, dispatch: mockDispatch } ); expect( mockDispatch ).toHaveBeenCalledWith( { type: REGISTER_ABILITY, @@ -100,7 +127,7 @@ describe( 'Store Actions', () => { } ); } ); - it( 'should register server-side abilities', () => { + it( 'should register server-side abilities', async () => { const ability: Ability = { name: 'test/server-ability', label: 'Server Ability', @@ -111,7 +138,7 @@ describe( 'Store Actions', () => { }; const action = registerAbility( ability ); - action( { select: mockSelect, dispatch: mockDispatch } ); + await action( { select: mockSelect, dispatch: mockDispatch } ); expect( mockDispatch ).toHaveBeenCalledWith( { type: REGISTER_ABILITY, @@ -119,7 +146,7 @@ describe( 'Store Actions', () => { } ); } ); - it( 'should validate and reject ability without name', () => { + it( 'should validate and reject ability without name', async () => { const ability: Ability = { name: '', label: 'Test Ability', @@ -130,13 +157,13 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( 'Ability name is required' ); + ).rejects.toThrow( 'Ability name is required' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); - it( 'should validate and reject ability with invalid name format', () => { + it( 'should validate and reject ability with invalid name format', async () => { const testCases = [ 'invalid', // No namespace 'my-plugin/feature/action', // Multiple slashes @@ -146,7 +173,7 @@ describe( 'Store Actions', () => { 'my plugin/feature', // Spaces not allowed ]; - testCases.forEach( ( invalidName ) => { + for ( const invalidName of testCases ) { const ability: Ability = { name: invalidName, label: 'Test Ability', @@ -157,16 +184,17 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( + ).rejects.toThrow( 'Ability name must be a string containing a namespace prefix' ); expect( mockDispatch ).not.toHaveBeenCalled(); - } ); + mockDispatch.mockClear(); + } } ); - it( 'should validate and reject ability without label', () => { + it( 'should validate and reject ability without label', async () => { const ability: Ability = { name: 'test/ability', label: '', @@ -177,13 +205,13 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( 'Ability "test/ability" must have a label' ); + ).rejects.toThrow( 'Ability "test/ability" must have a label' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); - it( 'should validate and reject ability without description', () => { + it( 'should validate and reject ability without description', async () => { const ability: Ability = { name: 'test/ability', label: 'Test Ability', @@ -194,13 +222,13 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( 'Ability "test/ability" must have a description' ); + ).rejects.toThrow( 'Ability "test/ability" must have a description' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); - it( 'should validate and reject ability without category', () => { + it( 'should validate and reject ability without category', async () => { const ability: Ability = { name: 'test/ability', label: 'Test Ability', @@ -211,13 +239,13 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( 'Ability "test/ability" must have a category' ); + ).rejects.toThrow( 'Ability "test/ability" must have a category' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); - it( 'should validate and reject ability with invalid category format', () => { + it( 'should validate and reject ability with invalid category format', async () => { const testCases = [ 'Data-Retrieval', // Uppercase letters 'data_retrieval', // Underscores not allowed @@ -228,7 +256,7 @@ describe( 'Store Actions', () => { 'data--retrieval', // Double dash ]; - testCases.forEach( ( invalidCategory ) => { + for ( const invalidCategory of testCases ) { const ability: Ability = { name: 'test/ability', label: 'Test Ability', @@ -239,16 +267,17 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( + ).rejects.toThrow( 'Ability "test/ability" has an invalid category. Category must be lowercase alphanumeric with dashes only' ); expect( mockDispatch ).not.toHaveBeenCalled(); - } ); + mockDispatch.mockClear(); + } } ); - it( 'should accept ability with valid category format', () => { + it( 'should accept ability with valid category format', async () => { const validCategories = [ 'data-retrieval', 'user-management', @@ -256,7 +285,7 @@ describe( 'Store Actions', () => { 'ecommerce', ]; - validCategories.forEach( ( validCategory ) => { + for ( const validCategory of validCategories ) { const ability: Ability = { name: 'test/ability-' + validCategory, label: 'Test Ability', @@ -265,20 +294,83 @@ describe( 'Store Actions', () => { callback: jest.fn(), }; + // Update mock to include this category + mockSelect.getAbilityCategory.mockImplementation( + ( slug: string ) => { + if ( slug === validCategory || slug === 'test-category' || slug === 'data-retrieval' ) { + return { + slug, + label: 'Test', + description: 'Test', + }; + } + return null; + } + ); + mockSelect.getAbility.mockReturnValue( null ); mockDispatch.mockClear(); const action = registerAbility( ability ); - action( { select: mockSelect, dispatch: mockDispatch } ); + await action( { select: mockSelect, dispatch: mockDispatch } ); expect( mockDispatch ).toHaveBeenCalledWith( { type: REGISTER_ABILITY, ability, } ); + } + } ); + + it( 'should validate and reject ability with non-existent category', async () => { + // Override getAbilityCategory to only return null + mockSelect.getAbilityCategory.mockReturnValue( null ); + + const ability: Ability = { + name: 'test/ability', + label: 'Test Ability', + description: 'Test description', + category: 'non-existent-category', + callback: jest.fn(), + }; + + const action = registerAbility( ability ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'Ability "test/ability" references non-existent category "non-existent-category". Please register the category first.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should accept ability with existing category', async () => { + mockSelect.getAbilityCategory.mockReturnValue( { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + } ); + + const ability: Ability = { + name: 'test/ability', + label: 'Test Ability', + description: 'Test description', + category: 'data-retrieval', + callback: jest.fn(), + }; + + const action = registerAbility( ability ); + await action( { select: mockSelect, dispatch: mockDispatch } ); + + expect( mockSelect.getAbilityCategory ).toHaveBeenCalledWith( + 'data-retrieval' + ); + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY, + ability, } ); } ); - it( 'should validate and reject ability with invalid callback', () => { + it( 'should validate and reject ability with invalid callback', async () => { const ability: Ability = { name: 'test/ability', label: 'Test Ability', @@ -289,15 +381,15 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( + ).rejects.toThrow( 'Ability "test/ability" has an invalid callback. Callback must be a function' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); - it( 'should validate and reject already registered ability', () => { + it( 'should validate and reject already registered ability', async () => { const existingAbility: Ability = { name: 'test/ability', label: 'Existing Ability', @@ -317,9 +409,9 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); - expect( () => + await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).toThrow( 'Ability "test/ability" is already registered' ); + ).rejects.toThrow( 'Ability "test/ability" is already registered' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); } ); diff --git a/packages/client/src/store/__tests__/resolvers.test.ts b/packages/client/src/store/__tests__/resolvers.test.ts index 536fbfa7..5295c602 100644 --- a/packages/client/src/store/__tests__/resolvers.test.ts +++ b/packages/client/src/store/__tests__/resolvers.test.ts @@ -10,10 +10,19 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { getAbilities, getAbility } from '../resolvers'; -import { receiveAbilities } from '../actions'; -import { ENTITY_KIND, ENTITY_NAME } from '../constants'; -import type { Ability } from '../../types'; +import { + getAbilities, + getAbility, + getAbilityCategories, + getAbilityCategory, +} from '../resolvers'; +import { receiveAbilities, receiveCategories } from '../actions'; +import { + ENTITY_KIND, + ENTITY_NAME, + ENTITY_NAME_CATEGORIES, +} from '../constants'; +import type { Ability, AbilityCategory } from '../../types'; // Mock the WordPress core data store jest.mock( '@wordpress/core-data', () => ( { @@ -346,4 +355,222 @@ describe( 'Store Resolvers', () => { ); } ); } ); + + describe( 'getAbilityCategories', () => { + it( 'should fetch and dispatch categories from the server', async () => { + const mockCategories: AbilityCategory[] = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + }, + ]; + + const mockResolveSelect = { + getEntityRecords: jest.fn().mockResolvedValue( mockCategories ), + }; + + const mockSelectInstance = { + getAbilityCategories: jest.fn().mockReturnValue( [] ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + + const resolver = getAbilityCategories(); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelectInstance, + } ); + + expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith( + coreStore + ); + expect( mockResolveSelect.getEntityRecords ).toHaveBeenCalledWith( + ENTITY_KIND, + ENTITY_NAME_CATEGORIES, + { per_page: -1 } + ); + expect( mockDispatch ).toHaveBeenCalledWith( + receiveCategories( mockCategories ) + ); + } ); + + it( 'should not fetch if store already has categories', async () => { + const existingCategories: AbilityCategory[] = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + ]; + + const mockResolveSelect = { + getEntityRecords: jest.fn(), + }; + + const mockSelectInstance = { + getAbilityCategories: jest + .fn() + .mockReturnValue( existingCategories ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + + const resolver = getAbilityCategories(); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelectInstance, + } ); + + // Should not fetch since store already has categories + expect( mockResolveSelect.getEntityRecords ).not.toHaveBeenCalled(); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should handle null response', async () => { + const mockResolveSelect = { + getEntityRecords: jest.fn().mockResolvedValue( null ), + }; + + const mockSelectInstance = { + getAbilityCategories: jest.fn().mockReturnValue( [] ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + + const resolver = getAbilityCategories(); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelectInstance, + } ); + + expect( mockDispatch ).toHaveBeenCalledWith( + receiveCategories( [] ) + ); + } ); + } ); + + describe( 'getAbilityCategory', () => { + it( 'should fetch and dispatch a specific category', async () => { + const mockCategory: AbilityCategory = { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }; + + const mockResolveSelect = { + getEntityRecord: jest.fn().mockResolvedValue( mockCategory ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + mockSelect.getAbilityCategory = jest.fn().mockReturnValue( null ); + + const resolver = getAbilityCategory( 'data-retrieval' ); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelect, + } ); + + expect( mockSelect.getAbilityCategory ).toHaveBeenCalledWith( + 'data-retrieval' + ); + expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith( + coreStore + ); + expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith( + ENTITY_KIND, + ENTITY_NAME_CATEGORIES, + 'data-retrieval' + ); + expect( mockDispatch ).toHaveBeenCalledWith( + receiveCategories( [ mockCategory ] ) + ); + } ); + + it( 'should not fetch if category already exists in store', async () => { + const existingCategory: AbilityCategory = { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Already in store', + }; + + mockSelect.getAbilityCategory = jest + .fn() + .mockReturnValue( existingCategory ); + + const resolver = getAbilityCategory( 'data-retrieval' ); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelect, + } ); + + expect( mockSelect.getAbilityCategory ).toHaveBeenCalledWith( + 'data-retrieval' + ); + expect( mockRegistry.resolveSelect ).not.toHaveBeenCalled(); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should handle non-existent categories', async () => { + const mockResolveSelect = { + getEntityRecord: jest.fn().mockResolvedValue( null ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + mockSelect.getAbilityCategory = jest.fn().mockReturnValue( null ); + + const resolver = getAbilityCategory( 'non-existent' ); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelect, + } ); + + expect( mockResolveSelect.getEntityRecord ).toHaveBeenCalledWith( + ENTITY_KIND, + ENTITY_NAME_CATEGORIES, + 'non-existent' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should handle categories with meta', async () => { + const mockCategory: AbilityCategory = { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + meta: { + priority: 'high', + }, + }; + + const mockResolveSelect = { + getEntityRecord: jest.fn().mockResolvedValue( mockCategory ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + mockSelect.getAbilityCategory = jest.fn().mockReturnValue( null ); + + const resolver = getAbilityCategory( 'user-management' ); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelect, + } ); + + expect( mockDispatch ).toHaveBeenCalledWith( + receiveCategories( [ mockCategory ] ) + ); + } ); + } ); } ); diff --git a/packages/client/src/store/__tests__/selectors.test.ts b/packages/client/src/store/__tests__/selectors.test.ts index 8577e17a..b8a47323 100644 --- a/packages/client/src/store/__tests__/selectors.test.ts +++ b/packages/client/src/store/__tests__/selectors.test.ts @@ -5,7 +5,12 @@ /** * Internal dependencies */ -import { getAbilities, getAbility } from '../selectors'; +import { + getAbilities, + getAbility, + getAbilityCategories, + getAbilityCategory, +} from '../selectors'; import type { AbilitiesState } from '../../types'; describe( 'Store Selectors', () => { @@ -31,6 +36,7 @@ describe( 'Store Selectors', () => { callback: jest.fn(), }, }, + categoriesBySlug: {}, }; const abilities = getAbilities( state ); @@ -47,6 +53,7 @@ describe( 'Store Selectors', () => { it( 'should return empty array when no abilities exist', () => { const state: AbilitiesState = { abilitiesByName: {}, + categoriesBySlug: {}, }; const abilities = getAbilities( state ); @@ -66,6 +73,7 @@ describe( 'Store Selectors', () => { output_schema: { type: 'object' }, }, }, + categoriesBySlug: {}, }; const result1 = getAbilities( state ); @@ -87,6 +95,7 @@ describe( 'Store Selectors', () => { output_schema: { type: 'object' }, }, }, + categoriesBySlug: {}, }; const state2: AbilitiesState = { @@ -101,6 +110,7 @@ describe( 'Store Selectors', () => { output_schema: { type: 'object' }, }, }, + categoriesBySlug: {}, }; const result1 = getAbilities( state1 ); @@ -140,9 +150,12 @@ describe( 'Store Selectors', () => { output_schema: { type: 'object' }, }, }, + categoriesBySlug: {}, }; - const result = getAbilities( state, { category: 'data-retrieval' } ); + const result = getAbilities( state, { + category: 'data-retrieval', + } ); expect( result ).toHaveLength( 2 ); expect( result ).toContainEqual( @@ -168,9 +181,12 @@ describe( 'Store Selectors', () => { output_schema: { type: 'object' }, }, }, + categoriesBySlug: {}, }; - const result = getAbilities( state, { category: 'non-existent-category' } ); + const result = getAbilities( state, { + category: 'non-existent-category', + } ); expect( result ).toEqual( [] ); } ); @@ -197,6 +213,7 @@ describe( 'Store Selectors', () => { callback: jest.fn(), }, }, + categoriesBySlug: {}, }; it( 'should return a specific ability by name', () => { @@ -216,6 +233,7 @@ describe( 'Store Selectors', () => { it( 'should handle empty state', () => { const emptyState: AbilitiesState = { abilitiesByName: {}, + categoriesBySlug: {}, }; const ability = getAbility( emptyState, 'test/ability' ); @@ -244,6 +262,7 @@ describe( 'Store Selectors', () => { output_schema: { type: 'object' }, }, }, + categoriesBySlug: {}, }; const ability = getAbility( @@ -258,4 +277,137 @@ describe( 'Store Selectors', () => { ); } ); } ); + + describe( 'getAbilityCategories', () => { + it( 'should return all categories as an array', () => { + const state: AbilitiesState = { + abilitiesByName: {}, + categoriesBySlug: { + 'data-retrieval': { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + 'user-management': { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + }, + }, + }; + + const categories = getAbilityCategories( state ); + + expect( categories ).toHaveLength( 2 ); + expect( categories ).toContainEqual( + state.categoriesBySlug[ 'data-retrieval' ] + ); + expect( categories ).toContainEqual( + state.categoriesBySlug[ 'user-management' ] + ); + } ); + + it( 'should return empty array when no categories exist', () => { + const state: AbilitiesState = { + abilitiesByName: {}, + categoriesBySlug: {}, + }; + + const categories = getAbilityCategories( state ); + + expect( categories ).toEqual( [] ); + } ); + } ); + + describe( 'getAbilityCategory', () => { + const state: AbilitiesState = { + abilitiesByName: {}, + categoriesBySlug: { + 'data-retrieval': { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + 'user-management': { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + meta: { + priority: 'high', + }, + }, + }, + }; + + it( 'should return a specific category by slug', () => { + const category = getAbilityCategory( state, 'data-retrieval' ); + + expect( category ).toEqual( + state.categoriesBySlug[ 'data-retrieval' ] + ); + } ); + + it( 'should return null if category not found', () => { + const category = getAbilityCategory( state, 'non-existent' ); + + expect( category ).toBeNull(); + } ); + + it( 'should handle empty state', () => { + const emptyState: AbilitiesState = { + abilitiesByName: {}, + categoriesBySlug: {}, + }; + + const category = getAbilityCategory( + emptyState, + 'data-retrieval' + ); + + expect( category ).toBeNull(); + } ); + + it( 'should return categories with meta', () => { + const category = getAbilityCategory( state, 'user-management' ); + + expect( category ).toEqual( + state.categoriesBySlug[ 'user-management' ] + ); + expect( category?.meta ).toBeDefined(); + expect( category?.meta?.priority ).toBe( 'high' ); + } ); + + it( 'should handle valid category slug formats', () => { + const stateWithVariousSlugs: AbilitiesState = { + abilitiesByName: {}, + categoriesBySlug: { + 'simple': { + slug: 'simple', + label: 'Simple', + description: 'Simple slug', + }, + 'with-dashes': { + slug: 'with-dashes', + label: 'With Dashes', + description: 'Slug with dashes', + }, + 'with123': { + slug: 'with123', + label: 'With Numbers', + description: 'Slug with numbers', + }, + }, + }; + + expect( + getAbilityCategory( stateWithVariousSlugs, 'simple' ) + ).toEqual( stateWithVariousSlugs.categoriesBySlug.simple ); + expect( + getAbilityCategory( stateWithVariousSlugs, 'with-dashes' ) + ).toEqual( stateWithVariousSlugs.categoriesBySlug[ 'with-dashes' ] ); + expect( + getAbilityCategory( stateWithVariousSlugs, 'with123' ) + ).toEqual( stateWithVariousSlugs.categoriesBySlug.with123 ); + } ); + } ); } ); diff --git a/packages/client/src/store/actions.ts b/packages/client/src/store/actions.ts index 83e44395..86a5b585 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -2,15 +2,18 @@ * WordPress dependencies */ import { sprintf } from '@wordpress/i18n'; +import { resolveSelect } from '@wordpress/data'; /** * Internal dependencies */ -import type { Ability } from '../types'; +import type { Ability, AbilityCategory } from '../types'; import { RECEIVE_ABILITIES, REGISTER_ABILITY, UNREGISTER_ABILITY, + RECEIVE_CATEGORIES, + STORE_NAME, } from './constants'; /** @@ -26,11 +29,25 @@ export function receiveAbilities( abilities: Ability[] ) { }; } +/** + * Returns an action object used to receive categories into the store. + * + * @param categories Array of categories to store. + * @return Action object. + */ +export function receiveCategories( categories: AbilityCategory[] ) { + return { + type: RECEIVE_CATEGORIES, + categories, + }; +} + /** * Registers an ability in the store. * * This action validates the ability before registration. If validation fails, - * an error will be thrown. + * an error will be thrown. Categories will be automatically fetched from the + * REST API if they haven't been loaded yet. * * @param ability The ability to register. * @return Action object or function. @@ -38,7 +55,7 @@ export function receiveAbilities( abilities: Ability[] ) { */ export function registerAbility( ability: Ability ) { // @ts-expect-error - registry types are not yet available - return ( { select, dispatch } ) => { + return async ( { select, dispatch } ) => { if ( ! ability.name ) { throw new Error( 'Ability name is required' ); } @@ -68,9 +85,7 @@ export function registerAbility( ability: Ability ) { ); } - // TODO: At the moment, only the format of an ability of a category is checked. - // We are not checking that the category is a valid registered category, as this - // would require a REST endpoint that does not exist at the moment. + // Validate category format if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( ability.category ) ) { throw new Error( sprintf( @@ -81,6 +96,26 @@ export function registerAbility( ability: Ability ) { ); } + // Ensure categories are loaded before validating + const categories = select.getAbilityCategories(); + if ( categories.length === 0 ) { + await resolveSelect( STORE_NAME ).getAbilityCategories(); + } + + // Validate that the category exists + const existingCategory = select.getAbilityCategory( + ability.category + ); + if ( ! existingCategory ) { + throw new Error( + sprintf( + 'Ability "%1$s" references non-existent category "%2$s". Please register the category first.', + ability.name, + ability.category + ) + ); + } + // Client-side abilities must have a callback if ( ability.callback && typeof ability.callback !== 'function' ) { throw new Error( diff --git a/packages/client/src/store/constants.ts b/packages/client/src/store/constants.ts index cca77b19..a88286f4 100644 --- a/packages/client/src/store/constants.ts +++ b/packages/client/src/store/constants.ts @@ -4,8 +4,10 @@ export const STORE_NAME = 'abilities-api/abilities'; export const ENTITY_KIND = 'root'; export const ENTITY_NAME = 'abilities'; +export const ENTITY_NAME_CATEGORIES = 'ability-categories'; // Action types export const RECEIVE_ABILITIES = 'RECEIVE_ABILITIES'; export const REGISTER_ABILITY = 'REGISTER_ABILITY'; export const UNREGISTER_ABILITY = 'UNREGISTER_ABILITY'; +export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES'; diff --git a/packages/client/src/store/index.ts b/packages/client/src/store/index.ts index 03cace9f..136b54c1 100644 --- a/packages/client/src/store/index.ts +++ b/packages/client/src/store/index.ts @@ -12,7 +12,12 @@ import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; import * as resolvers from './resolvers'; -import { STORE_NAME, ENTITY_KIND, ENTITY_NAME } from './constants'; +import { + STORE_NAME, + ENTITY_KIND, + ENTITY_NAME, + ENTITY_NAME_CATEGORIES, +} from './constants'; /** * The abilities store definition. @@ -37,4 +42,14 @@ dispatch( coreStore ).addEntities( [ label: __( 'Abilities' ), supportsPagination: true, }, + { + name: ENTITY_NAME_CATEGORIES, + kind: ENTITY_KIND, + key: 'slug', + baseURL: '/wp/v2/abilities/categories', + baseURLParams: { context: 'edit' }, + plural: 'ability-categories', + label: __( 'Ability Categories' ), + supportsPagination: true, + }, ] ); diff --git a/packages/client/src/store/reducer.ts b/packages/client/src/store/reducer.ts index f0fb995e..9fee0ac8 100644 --- a/packages/client/src/store/reducer.ts +++ b/packages/client/src/store/reducer.ts @@ -6,11 +6,12 @@ import { combineReducers } from '@wordpress/data'; /** * Internal dependencies */ -import type { Ability } from '../types'; +import type { Ability, AbilityCategory } from '../types'; import { RECEIVE_ABILITIES, REGISTER_ABILITY, UNREGISTER_ABILITY, + RECEIVE_CATEGORIES, } from './constants'; /** @@ -29,6 +30,12 @@ const ABILITY_KEYS = [ 'permissionCallback', ] as const; +/** + * Valid keys for an AbilityCategory object. + * Used to filter out non-standard properties from server responses. + */ +const CATEGORY_KEYS = [ 'slug', 'label', 'description', 'meta' ] as const; + /** * Filters an ability object to only include valid properties. * This ensures consistent shape regardless of source (server/client). @@ -49,10 +56,31 @@ function filterAbility( ability: any ): Ability { ); } +/** + * Filters a category object to only include valid properties. + * This ensures consistent shape regardless of source. + * + * @param category Raw category object that may contain extra properties. + * @return Filtered category with only valid properties. + */ +function filterCategory( category: any ): AbilityCategory { + return Object.keys( category ) + .filter( + ( key ) => + CATEGORY_KEYS.includes( key as any ) && + category[ key ] !== undefined + ) + .reduce( + ( obj, key ) => ( { ...obj, [ key ]: category[ key ] } ), + {} as AbilityCategory + ); +} + interface AbilitiesAction { type: string; abilities?: Ability[]; ability?: Ability; + categories?: AbilityCategory[]; name?: string; } @@ -102,6 +130,36 @@ function abilitiesByName( } } +const DEFAULT_CATEGORIES_STATE: Record< string, AbilityCategory > = {}; + +/** + * Reducer managing the categories by slug. + * + * @param state Current state. + * @param action Dispatched action. + * @return New state. + */ +function categoriesBySlug( + state: Record< string, AbilityCategory > = DEFAULT_CATEGORIES_STATE, + action: AbilitiesAction +): Record< string, AbilityCategory > { + switch ( action.type ) { + case RECEIVE_CATEGORIES: { + if ( ! action.categories ) { + return state; + } + const newState = { ...state }; + action.categories.forEach( ( category ) => { + newState[ category.slug ] = filterCategory( category ); + } ); + return newState; + } + default: + return state; + } +} + export default combineReducers( { abilitiesByName, + categoriesBySlug, } ); diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index 8653bd48..ad4aee4f 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -6,9 +6,9 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import type { Ability } from '../types'; -import { ENTITY_KIND, ENTITY_NAME } from './constants'; -import { receiveAbilities } from './actions'; +import type { Ability, AbilityCategory } from '../types'; +import { ENTITY_KIND, ENTITY_NAME, ENTITY_NAME_CATEGORIES } from './constants'; +import { receiveAbilities, receiveCategories } from './actions'; /** * Resolver for getAbilities selector. @@ -74,3 +74,56 @@ export function getAbility( name: string ) { } }; } + +/** + * Resolver for getAbilityCategories selector. + * Fetches all categories from the server. + * + * The resolver only fetches once and stores all categories. + */ +export function getAbilityCategories() { + // @ts-expect-error - registry types are not yet available + return async ( { dispatch, registry, select } ) => { + const existingCategories = select.getAbilityCategories(); + if ( existingCategories && existingCategories.length > 0 ) { + return; + } + + const categories = await registry + .resolveSelect( coreStore ) + .getEntityRecords( ENTITY_KIND, ENTITY_NAME_CATEGORIES, { + per_page: -1, + } ); + + dispatch( receiveCategories( categories || [] ) ); + }; +} + +/** + * Resolver for getAbilityCategory selector. + * Fetches a specific category from the server if not already in store. + * + * @param slug Category slug. + */ +export function getAbilityCategory( slug: string ) { + // @ts-expect-error - registry types are not yet available + return async ( { dispatch, registry, select } ) => { + const existingCategory = select.getAbilityCategory( slug ); + if ( existingCategory ) { + return; + } + + try { + const category = await registry + .resolveSelect( coreStore ) + .getEntityRecord( ENTITY_KIND, ENTITY_NAME_CATEGORIES, slug ); + + if ( category ) { + dispatch( receiveCategories( [ category ] ) ); + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.debug( `Category not found: ${ slug }` ); + } + }; +} diff --git a/packages/client/src/store/selectors.ts b/packages/client/src/store/selectors.ts index e81b10fa..d497de36 100644 --- a/packages/client/src/store/selectors.ts +++ b/packages/client/src/store/selectors.ts @@ -6,7 +6,12 @@ import { createSelector } from '@wordpress/data'; /** * Internal dependencies */ -import type { Ability, AbilitiesQueryArgs, AbilitiesState } from '../types'; +import type { + Ability, + AbilityCategory, + AbilitiesQueryArgs, + AbilitiesState, +} from '../types'; /** * Returns all registered abilities. @@ -17,7 +22,10 @@ import type { Ability, AbilitiesQueryArgs, AbilitiesState } from '../types'; * @return Array of abilities. */ export const getAbilities = createSelector( - ( state: AbilitiesState, { category }: AbilitiesQueryArgs = {} ): Ability[] => { + ( + state: AbilitiesState, + { category }: AbilitiesQueryArgs = {} + ): Ability[] => { const abilities = Object.values( state.abilitiesByName ); if ( category ) { return abilities.filter( @@ -45,3 +53,29 @@ export function getAbility( ): Ability | null { return state.abilitiesByName[ name ] || null; } + +/** + * Returns all registered ability categories. + * + * @param state Store state. + * @return Array of categories. + */ +export function getAbilityCategories( + state: AbilitiesState +): AbilityCategory[] { + return Object.values( state.categoriesBySlug ); +} + +/** + * Returns a specific ability category by slug. + * + * @param state Store state. + * @param slug Category slug. + * @return Category object or null if not found. + */ +export function getAbilityCategory( + state: AbilitiesState, + slug: string +): AbilityCategory | null { + return state.categoriesBySlug[ slug ] || null; +} diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 38d79799..1b093801 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -101,6 +101,39 @@ export interface AbilitiesQueryArgs { category?: string; } +/** + * Represents an ability category in the WordPress Abilities API. + * + * @see WP_Ability_Category + */ +export interface AbilityCategory { + /** + * The unique slug identifier for the category. + * Must be lowercase alphanumeric with dashes only. + * Example: 'data-retrieval', 'user-management' + * @see WP_Ability_Category::get_slug() + */ + slug: string; + + /** + * The human-readable label for the category. + * @see WP_Ability_Category::get_label() + */ + label: string; + + /** + * The detailed description of the category. + * @see WP_Ability_Category::get_description() + */ + description: string; + + /** + * Metadata about the category. + * @see WP_Ability_Category::get_meta() + */ + meta?: Record< string, any >; +} + /** * The state shape for the abilities store. */ @@ -109,6 +142,11 @@ export interface AbilitiesState { * Map of ability names to ability objects. */ abilitiesByName: Record< string, Ability >; + + /** + * Map of category slugs to category objects. + */ + categoriesBySlug: Record< string, AbilityCategory >; } /** From 036645020cce84d6d7c92689d896944ed8d4ff86 Mon Sep 17 00:00:00 2001 From: Em Date: Wed, 15 Oct 2025 09:00:25 -0400 Subject: [PATCH 3/6] fix: increase test coverage --- docs/5.rest-api.md | 2 +- ...lass-wp-rest-abilities-list-controller.php | 2 +- packages/client/src/__tests__/api.test.ts | 8 +- .../src/store/__tests__/actions.test.ts | 127 +++++++-- .../src/store/__tests__/reducer.test.ts | 249 ++++++++++++++++++ .../src/store/__tests__/resolvers.test.ts | 6 +- .../src/store/__tests__/selectors.test.ts | 13 +- packages/client/src/store/actions.ts | 10 +- packages/client/src/store/resolvers.ts | 2 +- .../wpRestAbilitiesListController.php | 22 +- 10 files changed, 374 insertions(+), 67 deletions(-) diff --git a/docs/5.rest-api.md b/docs/5.rest-api.md index 6f0fe138..bbadba3b 100644 --- a/docs/5.rest-api.md +++ b/docs/5.rest-api.md @@ -4,7 +4,7 @@ The WordPress Abilities API provides REST endpoints that allow external systems ## User access -Access to all Abilities REST API endpoints requires an authenticated user with administrator privileges (`manage_options` capability). This includes listing abilities, retrieving individual abilities, listing categories, and executing abilities. Access to execute individual Abilities is further restricted based on the `permission_callback()` of the Ability. +Access to all Abilities REST API endpoints requires an authenticated user (see the [Authentication](#authentication) section). Access to execute individual Abilities is restricted based on the `permission_callback()` of the Ability. ## Controlling REST API Exposure diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 45597de4..54e393f4 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -189,7 +189,7 @@ public function get_item( $request ) { * @return bool True if the request has read access. */ public function get_permissions_check( $request ) { - return current_user_can( 'manage_options' ); + return current_user_can( 'read' ); } /** diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index d99c3bb8..5f761d0d 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -537,9 +537,7 @@ describe( 'API functions', () => { } ); it( 'should return empty array when no categories exist', async () => { - const mockGetAbilityCategories = jest - .fn() - .mockResolvedValue( [] ); + const mockGetAbilityCategories = jest.fn().mockResolvedValue( [] ); ( resolveSelect as jest.Mock ).mockReturnValue( { getAbilityCategories: mockGetAbilityCategories, } ); @@ -575,9 +573,7 @@ describe( 'API functions', () => { } ); it( 'should return null if category not found', async () => { - const mockGetAbilityCategory = jest - .fn() - .mockResolvedValue( null ); + const mockGetAbilityCategory = jest.fn().mockResolvedValue( null ); ( resolveSelect as jest.Mock ).mockReturnValue( { getAbilityCategory: mockGetAbilityCategory, } ); diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index 5e5a2ab0..5a75d809 100644 --- a/packages/client/src/store/__tests__/actions.test.ts +++ b/packages/client/src/store/__tests__/actions.test.ts @@ -2,6 +2,11 @@ * Tests for store actions. */ +/** + * WordPress dependencies + */ +import { resolveSelect } from '@wordpress/data'; + /** * Internal dependencies */ @@ -17,6 +22,11 @@ import { } from '../constants'; import type { Ability } from '../../types'; +// Mock the WordPress data store +jest.mock( '@wordpress/data', () => ( { + resolveSelect: jest.fn(), +} ) ); + describe( 'Store Actions', () => { describe( 'receiveAbilities', () => { it( 'should create an action to receive abilities', () => { @@ -224,7 +234,9 @@ describe( 'Store Actions', () => { await expect( action( { select: mockSelect, dispatch: mockDispatch } ) - ).rejects.toThrow( 'Ability "test/ability" must have a description' ); + ).rejects.toThrow( + 'Ability "test/ability" must have a description' + ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); @@ -294,19 +306,23 @@ describe( 'Store Actions', () => { callback: jest.fn(), }; - // Update mock to include this category - mockSelect.getAbilityCategory.mockImplementation( - ( slug: string ) => { - if ( slug === validCategory || slug === 'test-category' || slug === 'data-retrieval' ) { - return { - slug, - label: 'Test', - description: 'Test', - }; - } - return null; - } - ); + mockSelect.getAbilityCategories.mockReturnValue( [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + { + slug: validCategory, + label: 'Test Category', + description: 'Test', + }, + ] ); mockSelect.getAbility.mockReturnValue( null ); mockDispatch.mockClear(); @@ -322,8 +338,18 @@ describe( 'Store Actions', () => { } ); it( 'should validate and reject ability with non-existent category', async () => { - // Override getAbilityCategory to only return null - mockSelect.getAbilityCategory.mockReturnValue( null ); + mockSelect.getAbilityCategories.mockReturnValue( [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + ] ); const ability: Ability = { name: 'test/ability', @@ -344,11 +370,18 @@ describe( 'Store Actions', () => { } ); it( 'should accept ability with existing category', async () => { - mockSelect.getAbilityCategory.mockReturnValue( { - slug: 'data-retrieval', - label: 'Data Retrieval', - description: 'Abilities that retrieve data', - } ); + mockSelect.getAbilityCategories.mockReturnValue( [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + ] ); const ability: Ability = { name: 'test/ability', @@ -361,9 +394,8 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); await action( { select: mockSelect, dispatch: mockDispatch } ); - expect( mockSelect.getAbilityCategory ).toHaveBeenCalledWith( - 'data-retrieval' - ); + // We check the categories array now, not calling getAbilityCategory + expect( mockSelect.getAbilityCategories ).toHaveBeenCalled(); expect( mockDispatch ).toHaveBeenCalledWith( { type: REGISTER_ABILITY, ability, @@ -414,6 +446,53 @@ describe( 'Store Actions', () => { ).rejects.toThrow( 'Ability "test/ability" is already registered' ); expect( mockDispatch ).not.toHaveBeenCalled(); } ); + + it( 'should load categories when store is empty before validation', async () => { + // First call returns empty, second call returns loaded categories + let callCount = 0; + mockSelect.getAbilityCategories.mockImplementation( () => { + callCount++; + if ( callCount === 1 ) { + return []; // Empty on first call + } + return [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category', + }, + ]; // Loaded on second call + } ); + + // Mock resolveSelect to return a mock that resolves the getAbilityCategories call + const mockResolveSelectFn = jest.fn().mockReturnValue( { + getAbilityCategories: jest.fn().mockResolvedValue( undefined ), + } ); + ( resolveSelect as jest.Mock ).mockImplementation( + mockResolveSelectFn + ); + + const ability: Ability = { + name: 'test/ability', + label: 'Test Ability', + description: 'Test description', + category: 'test-category', + callback: jest.fn(), + }; + + const action = registerAbility( ability ); + await action( { select: mockSelect, dispatch: mockDispatch } ); + + // Should have called getAbilityCategories twice (once to check, once after loading) + expect( mockSelect.getAbilityCategories ).toHaveBeenCalledTimes( + 2 + ); + // Should have successfully registered + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY, + ability, + } ); + } ); } ); describe( 'unregisterAbility', () => { diff --git a/packages/client/src/store/__tests__/reducer.test.ts b/packages/client/src/store/__tests__/reducer.test.ts index f3d65b04..c1accb29 100644 --- a/packages/client/src/store/__tests__/reducer.test.ts +++ b/packages/client/src/store/__tests__/reducer.test.ts @@ -10,6 +10,7 @@ import { RECEIVE_ABILITIES, REGISTER_ABILITY, UNREGISTER_ABILITY, + RECEIVE_CATEGORIES, } from '../constants'; describe( 'Store Reducer', () => { @@ -390,4 +391,252 @@ describe( 'Store Reducer', () => { } ); } ); } ); + + describe( 'categoriesBySlug', () => { + const defaultState = {}; + + describe( 'RECEIVE_CATEGORIES', () => { + it( 'should add categories to the state', () => { + const categories = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + }, + ]; + + const action = { + type: RECEIVE_CATEGORIES, + categories, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toHaveProperty( + 'data-retrieval' + ); + expect( state.categoriesBySlug ).toHaveProperty( + 'user-management' + ); + expect( state.categoriesBySlug[ 'data-retrieval' ].label ).toBe( + 'Data Retrieval' + ); + expect( + state.categoriesBySlug[ 'user-management' ].label + ).toBe( 'User Management' ); + } ); + + it( 'should filter out _links from server responses', () => { + const categories = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Test category with links', + _links: { + self: { + href: '/wp/v2/abilities/categories/data-retrieval', + }, + collection: { + href: '/wp/v2/abilities/categories', + }, + }, + }, + ]; + + const action = { + type: RECEIVE_CATEGORIES, + categories, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( + state.categoriesBySlug[ 'data-retrieval' ] + ).not.toHaveProperty( '_links' ); + expect( state.categoriesBySlug[ 'data-retrieval' ].slug ).toBe( + 'data-retrieval' + ); + expect( state.categoriesBySlug[ 'data-retrieval' ].label ).toBe( + 'Data Retrieval' + ); + } ); + + it( 'should filter out _embedded from server responses', () => { + const categories = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Test category with embedded', + _embedded: { + author: { id: 1, name: 'Admin' }, + }, + }, + ]; + + const action = { + type: RECEIVE_CATEGORIES, + categories, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( + state.categoriesBySlug[ 'data-retrieval' ] + ).not.toHaveProperty( '_embedded' ); + } ); + + it( 'should preserve all valid category properties', () => { + const categories = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Full test category.', + meta: { + priority: 'high', + color: 'blue', + }, + // Extra properties that should be filtered out + _links: { self: { href: '/test' } }, + _embedded: { test: 'value' }, + extra_field: 'should be removed', + }, + ]; + + const action = { + type: RECEIVE_CATEGORIES, + categories, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + const category = state.categoriesBySlug[ 'data-retrieval' ]; + + // Should have valid properties + expect( category.slug ).toBe( 'data-retrieval' ); + expect( category.label ).toBe( 'Data Retrieval' ); + expect( category.description ).toBe( 'Full test category.' ); + expect( category.meta ).toEqual( { + priority: 'high', + color: 'blue', + } ); + + // Should NOT have invalid properties + expect( category ).not.toHaveProperty( '_links' ); + expect( category ).not.toHaveProperty( '_embedded' ); + expect( category ).not.toHaveProperty( 'extra_field' ); + } ); + + it( 'should merge with existing categories', () => { + const initialState = { + 'existing-category': { + slug: 'existing-category', + label: 'Existing Category', + description: 'Already in store', + }, + }; + + const categories = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'New category', + }, + ]; + + const action = { + type: RECEIVE_CATEGORIES, + categories, + }; + + const state = reducer( + { categoriesBySlug: initialState, abilitiesByName: {} }, + action + ); + + // Should have both old and new categories + expect( state.categoriesBySlug ).toHaveProperty( + 'existing-category' + ); + expect( state.categoriesBySlug ).toHaveProperty( + 'data-retrieval' + ); + } ); + } ); + + describe( 'Edge cases', () => { + it( 'should handle undefined categories in RECEIVE_CATEGORIES', () => { + const action = { + type: RECEIVE_CATEGORIES, + categories: undefined, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toEqual( defaultState ); + } ); + + it( 'should handle empty categories array', () => { + const action = { + type: RECEIVE_CATEGORIES, + categories: [], + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toEqual( defaultState ); + } ); + + it( 'should handle undefined properties gracefully', () => { + const categories = [ + { + slug: 'minimal', + label: 'Minimal', + description: 'Minimal category with undefined meta', + meta: undefined, + }, + ]; + + const action = { + type: RECEIVE_CATEGORIES, + categories, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + const category = state.categoriesBySlug.minimal; + + expect( category.slug ).toBe( 'minimal' ); + expect( category.label ).toBe( 'Minimal' ); + expect( category.description ).toBe( + 'Minimal category with undefined meta' + ); + // Undefined properties should not be present + expect( category ).not.toHaveProperty( 'meta' ); + } ); + } ); + } ); } ); diff --git a/packages/client/src/store/__tests__/resolvers.test.ts b/packages/client/src/store/__tests__/resolvers.test.ts index 5295c602..74026eff 100644 --- a/packages/client/src/store/__tests__/resolvers.test.ts +++ b/packages/client/src/store/__tests__/resolvers.test.ts @@ -17,11 +17,7 @@ import { getAbilityCategory, } from '../resolvers'; import { receiveAbilities, receiveCategories } from '../actions'; -import { - ENTITY_KIND, - ENTITY_NAME, - ENTITY_NAME_CATEGORIES, -} from '../constants'; +import { ENTITY_KIND, ENTITY_NAME, ENTITY_NAME_CATEGORIES } from '../constants'; import type { Ability, AbilityCategory } from '../../types'; // Mock the WordPress core data store diff --git a/packages/client/src/store/__tests__/selectors.test.ts b/packages/client/src/store/__tests__/selectors.test.ts index b8a47323..bb644e26 100644 --- a/packages/client/src/store/__tests__/selectors.test.ts +++ b/packages/client/src/store/__tests__/selectors.test.ts @@ -359,10 +359,7 @@ describe( 'Store Selectors', () => { categoriesBySlug: {}, }; - const category = getAbilityCategory( - emptyState, - 'data-retrieval' - ); + const category = getAbilityCategory( emptyState, 'data-retrieval' ); expect( category ).toBeNull(); } ); @@ -381,7 +378,7 @@ describe( 'Store Selectors', () => { const stateWithVariousSlugs: AbilitiesState = { abilitiesByName: {}, categoriesBySlug: { - 'simple': { + simple: { slug: 'simple', label: 'Simple', description: 'Simple slug', @@ -391,7 +388,7 @@ describe( 'Store Selectors', () => { label: 'With Dashes', description: 'Slug with dashes', }, - 'with123': { + with123: { slug: 'with123', label: 'With Numbers', description: 'Slug with numbers', @@ -404,7 +401,9 @@ describe( 'Store Selectors', () => { ).toEqual( stateWithVariousSlugs.categoriesBySlug.simple ); expect( getAbilityCategory( stateWithVariousSlugs, 'with-dashes' ) - ).toEqual( stateWithVariousSlugs.categoriesBySlug[ 'with-dashes' ] ); + ).toEqual( + stateWithVariousSlugs.categoriesBySlug[ 'with-dashes' ] + ); expect( getAbilityCategory( stateWithVariousSlugs, 'with123' ) ).toEqual( stateWithVariousSlugs.categoriesBySlug.with123 ); diff --git a/packages/client/src/store/actions.ts b/packages/client/src/store/actions.ts index 86a5b585..64e45ea5 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -97,14 +97,16 @@ export function registerAbility( ability: Ability ) { } // Ensure categories are loaded before validating - const categories = select.getAbilityCategories(); + let categories = select.getAbilityCategories(); if ( categories.length === 0 ) { await resolveSelect( STORE_NAME ).getAbilityCategories(); + categories = select.getAbilityCategories(); } - // Validate that the category exists - const existingCategory = select.getAbilityCategory( - ability.category + // Validate that the category exists in the loaded categories + // We check the array directly to avoid triggering the single-category resolver + const existingCategory = categories.find( + ( cat: AbilityCategory ) => cat.slug === ability.category ); if ( ! existingCategory ) { throw new Error( diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index ad4aee4f..a328c96e 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -6,7 +6,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import type { Ability, AbilityCategory } from '../types'; +import type { Ability } from '../types'; import { ENTITY_KIND, ENTITY_NAME, ENTITY_NAME_CATEGORIES } from './constants'; import { receiveAbilities, receiveCategories } from './actions'; diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 5b30b7e5..24093c46 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -29,10 +29,10 @@ class Tests_REST_API_WpRestAbilitiesListController extends WP_UnitTestCase { public static function set_up_before_class(): void { parent::set_up_before_class(); - // Create a test user with admin capabilities + // Create a test user with read capabilities self::$user_id = self::factory()->user->create( array( - 'role' => 'administrator', + 'role' => 'subscriber', ) ); } @@ -323,9 +323,9 @@ public function test_get_item_not_show_in_rest(): void { } /** - * Test permission check for listing abilities requires authentication. + * Test permission check for listing abilities. */ - public function test_get_items_permission_denied_unauthenticated(): void { + public function test_get_items_permission_denied(): void { // Test with non-logged-in user wp_set_current_user( 0 ); @@ -335,20 +335,6 @@ public function test_get_items_permission_denied_unauthenticated(): void { $this->assertEquals( 401, $response->get_status() ); } - /** - * Test permission check for listing abilities requires admin. - */ - public function test_get_items_permission_denied_non_admin(): void { - // Create and use a subscriber user (non-admin) - $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); - wp_set_current_user( $subscriber_id ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); - $response = $this->server->dispatch( $request ); - - $this->assertEquals( 403, $response->get_status() ); - } - /** * Test pagination headers. */ From cc55e76eabe5e7d29028fd3e6439cdeb536ac9b7 Mon Sep 17 00:00:00 2001 From: Em Date: Thu, 16 Oct 2025 09:44:33 -0400 Subject: [PATCH 4/6] add: registerAbilityCategory and unregisterAbilityCategory --- packages/client/README.md | 55 +++ packages/client/src/api.ts | 61 +++ packages/client/src/index.ts | 3 + .../src/store/__tests__/actions.test.ts | 396 +++++++++++++++++- .../src/store/__tests__/reducer.test.ts | 217 ++++++++++ packages/client/src/store/actions.ts | 101 ++++- packages/client/src/store/constants.ts | 2 + packages/client/src/store/reducer.ts | 21 + packages/client/src/types.ts | 23 + 9 files changed, 877 insertions(+), 2 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 3528bc9d..9b276eca 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -151,6 +151,61 @@ await registerAbility( { } ); ``` +#### `unregisterAbility( name: string ): void` + +Unregisters a client-side ability from the store. + +```javascript +import { unregisterAbility } from '@wordpress/abilities'; + +unregisterAbility( 'my-plugin/navigate' ); +``` + +#### `registerAbilityCategory( slug: string, args: AbilityCategoryArgs ): Promise` + +Registers a client-side ability category. This is useful when registering client-side abilities that introduce new categories not defined by the server. + +```javascript +import { registerAbilityCategory } from '@wordpress/abilities'; + +// Register a new category +await registerAbilityCategory( 'block-editor', { + label: 'Block Editor', + description: 'Abilities for interacting with the WordPress block editor', +} ); + +// Register a category with optional metadata +await registerAbilityCategory( 'custom-category', { + label: 'Custom Category', + description: 'A category for custom abilities', + meta: { + color: '#ff0000', + }, +} ); + +// Then register abilities using the new category +await registerAbility( { + name: 'my-plugin/insert-block', + label: 'Insert Block', + description: 'Inserts a block into the editor', + category: 'block-editor', // Uses the client-registered category + callback: async ( { blockType } ) => { + // Implementation + return { success: true }; + }, +} ); +``` + +#### `unregisterAbilityCategory( slug: string ): void` + +Unregisters an ability category from the store. + +```javascript +import { unregisterAbilityCategory } from '@wordpress/abilities'; + +unregisterAbilityCategory( 'block-editor' ); +``` + #### `executeAbility( name: string, input?: Record ): Promise` Executes an ability with optional input parameters. The HTTP method is automatically determined based on the ability's annotations: diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index c8d73709..d509ce81 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -13,6 +13,7 @@ import { store } from './store'; import type { Ability, AbilityCategory, + AbilityCategoryArgs, AbilitiesQueryArgs, AbilityInput, AbilityOutput, @@ -114,6 +115,66 @@ export function unregisterAbility( name: string ): void { dispatch( store ).unregisterAbility( name ); } +/** + * Register a client-side ability category. + * + * Categories registered on the client are stored alongside server-side categories + * in the same store and can be used when registering client side abilities. + * This is useful when registering client-side abilities that introduce new + * categories not defined by the server. + * + * Categories will be automatically fetched from the REST API if they haven't been + * loaded yet to check for duplicates against server-side categories. + * + * @param slug Category slug (lowercase alphanumeric with dashes only). + * @param args Category arguments (label, description, optional meta). + * @return Promise that resolves when registration is complete. + * @throws {Error} If the category fails validation. + * + * @example + * ```js + * // Register a new category for block editor abilities + * await registerAbilityCategory('block-editor', { + * label: 'Block Editor', + * description: 'Abilities for interacting with the WordPress block editor' + * }); + * + * // Then register abilities using this category + * await registerAbility({ + * name: 'my-plugin/insert-block', + * label: 'Insert Block', + * description: 'Inserts a block into the editor', + * category: 'block-editor', + * callback: async ({ blockType }) => { + * // Implementation + * return { success: true }; + * } + * }); + * ``` + */ +export async function registerAbilityCategory( + slug: string, + args: AbilityCategoryArgs +): Promise< void > { + await dispatch( store ).registerAbilityCategory( slug, args ); +} + +/** + * Unregister an ability category. + * + * Removes a category from the store. + * + * @param slug The category slug to unregister. + * + * @example + * ```js + * unregisterAbilityCategory('block-editor'); + * ``` + */ +export function unregisterAbilityCategory( slug: string ): void { + dispatch( store ).unregisterAbilityCategory( slug ); +} + /** * Execute a client-side ability. * diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7b70c4b3..75f9469a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -36,6 +36,8 @@ export { executeAbility, registerAbility, unregisterAbility, + registerAbilityCategory, + unregisterAbilityCategory, } from './api'; /** @@ -64,6 +66,7 @@ export { store } from './store'; export type { Ability, AbilityCategory, + AbilityCategoryArgs, AbilitiesState, AbilitiesQueryArgs, AbilityCallback, diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index 5a75d809..b6cf58aa 100644 --- a/packages/client/src/store/__tests__/actions.test.ts +++ b/packages/client/src/store/__tests__/actions.test.ts @@ -14,13 +14,23 @@ import { receiveAbilities, registerAbility, unregisterAbility, + receiveCategories, + registerAbilityCategory, + unregisterAbilityCategory, } from '../actions'; import { RECEIVE_ABILITIES, REGISTER_ABILITY, UNREGISTER_ABILITY, + RECEIVE_CATEGORIES, + REGISTER_ABILITY_CATEGORY, + UNREGISTER_ABILITY_CATEGORY, } from '../constants'; -import type { Ability } from '../../types'; +import type { + Ability, + AbilityCategory, + AbilityCategoryArgs, +} from '../../types'; // Mock the WordPress data store jest.mock( '@wordpress/data', () => ( { @@ -516,4 +526,388 @@ describe( 'Store Actions', () => { } ); } ); } ); + + describe( 'receiveCategories', () => { + it( 'should create an action to receive categories', () => { + const categories: AbilityCategory[] = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + { + slug: 'user-management', + label: 'User Management', + description: 'Abilities for managing users', + }, + ]; + + const action = receiveCategories( categories ); + + expect( action ).toEqual( { + type: RECEIVE_CATEGORIES, + categories, + } ); + } ); + + it( 'should handle empty categories array', () => { + const categories: AbilityCategory[] = []; + const action = receiveCategories( categories ); + + expect( action ).toEqual( { + type: RECEIVE_CATEGORIES, + categories: [], + } ); + } ); + } ); + + describe( 'registerAbilityCategory', () => { + let mockSelect: any; + let mockDispatch: jest.Mock; + + beforeEach( () => { + jest.clearAllMocks(); + mockSelect = { + getAbilityCategory: jest.fn().mockReturnValue( null ), + getAbilityCategories: jest.fn().mockReturnValue( [] ), + }; + mockDispatch = jest.fn(); + + // Mock resolveSelect to return a mock that resolves the getAbilityCategories call + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest.fn().mockResolvedValue( undefined ), + } ); + } ); + + it( 'should register a valid category', async () => { + const slug = 'test-category'; + const args: AbilityCategoryArgs = { + label: 'Test Category', + description: 'A test category for testing', + }; + + const action = registerAbilityCategory( slug, args ); + await action( { select: mockSelect, dispatch: mockDispatch } ); + + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY_CATEGORY, + category: { + slug, + label: args.label, + description: args.description, + }, + } ); + } ); + + it( 'should register a category with meta', async () => { + const slug = 'test-category'; + const args: AbilityCategoryArgs = { + label: 'Test Category', + description: 'A test category', + meta: { foo: 'bar', nested: { key: 'value' } }, + }; + + const action = registerAbilityCategory( slug, args ); + await action( { select: mockSelect, dispatch: mockDispatch } ); + + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY_CATEGORY, + category: { + slug, + label: args.label, + description: args.description, + meta: args.meta, + }, + } ); + } ); + + it( 'should validate and reject empty slug', async () => { + const args: AbilityCategoryArgs = { + label: 'Test', + description: 'Test', + }; + + const action = registerAbilityCategory( '', args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( 'Category slug is required' ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject invalid slug formats', async () => { + const testCases = [ + 'Data-Retrieval', // Uppercase + 'data_retrieval', // Underscores + 'data.retrieval', // Dots + 'data/retrieval', // Slashes + '-data-retrieval', // Leading dash + 'data-retrieval-', // Trailing dash + 'data--retrieval', // Double dash + 'data retrieval', // Spaces + 'data!retrieval', // Special characters + ]; + + const args: AbilityCategoryArgs = { + label: 'Test', + description: 'Test', + }; + + for ( const invalidSlug of testCases ) { + const action = registerAbilityCategory( invalidSlug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'Category slug must contain only lowercase alphanumeric characters and dashes' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + mockDispatch.mockClear(); + } + } ); + + it( 'should accept valid slug formats', async () => { + const validSlugs = [ + 'data-retrieval', + 'user-management', + 'analytics-123', + 'ecommerce', + 'a', + '123', + 'test-multiple-words-with-dashes', + ]; + + const args: AbilityCategoryArgs = { + label: 'Test Category', + description: 'Test description', + }; + + for ( const validSlug of validSlugs ) { + mockSelect.getAbilityCategory.mockReturnValue( null ); + mockDispatch.mockClear(); + + const action = registerAbilityCategory( validSlug, args ); + await action( { select: mockSelect, dispatch: mockDispatch } ); + + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY_CATEGORY, + category: { + slug: validSlug, + label: args.label, + description: args.description, + }, + } ); + } + } ); + + it( 'should validate and reject missing label', async () => { + const slug = 'test-category'; + const args = { + label: '', + description: 'Test', + } as AbilityCategoryArgs; + + const action = registerAbilityCategory( slug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'The category properties must contain a `label` string.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject non-string label', async () => { + const slug = 'test-category'; + const args = { + label: 123 as any, + description: 'Test', + }; + + const action = registerAbilityCategory( slug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'The category properties must contain a `label` string.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject missing description', async () => { + const slug = 'test-category'; + const args = { + label: 'Test', + description: '', + } as AbilityCategoryArgs; + + const action = registerAbilityCategory( slug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'The category properties must contain a `description` string.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject non-string description', async () => { + const slug = 'test-category'; + const args = { + label: 'Test', + description: 123 as any, + }; + + const action = registerAbilityCategory( slug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'The category properties must contain a `description` string.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject non-object meta', async () => { + const slug = 'test-category'; + const args = { + label: 'Test', + description: 'Test', + meta: 'invalid' as any, + }; + + const action = registerAbilityCategory( slug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'The category properties should provide a valid `meta` array.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject array as meta', async () => { + const slug = 'test-category'; + const args = { + label: 'Test', + description: 'Test', + meta: [ 'invalid' ] as any, + }; + + const action = registerAbilityCategory( slug, args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'The category properties should provide a valid `meta` array.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject already registered category', async () => { + const existingCategory: AbilityCategory = { + slug: 'test-category', + label: 'Existing Category', + description: 'Already registered', + }; + + mockSelect.getAbilityCategory.mockReturnValue( existingCategory ); + + const args: AbilityCategoryArgs = { + label: 'Test', + description: 'Test', + }; + + const action = registerAbilityCategory( 'test-category', args ); + + await expect( + action( { select: mockSelect, dispatch: mockDispatch } ) + ).rejects.toThrow( + 'Category "test-category" is already registered.' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should allow registering ability after registering category', async () => { + // First register a category + const categorySlug = 'new-category'; + const categoryArgs: AbilityCategoryArgs = { + label: 'New Category', + description: 'A newly registered category', + }; + + const categoryAction = registerAbilityCategory( + categorySlug, + categoryArgs + ); + await categoryAction( { + select: mockSelect, + dispatch: mockDispatch, + } ); + + // Verify category was registered + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY_CATEGORY, + category: { + slug: categorySlug, + label: categoryArgs.label, + description: categoryArgs.description, + }, + } ); + + // Now mock that the category exists for ability registration + mockSelect.getAbilityCategories = jest.fn().mockReturnValue( [ + { + slug: categorySlug, + label: categoryArgs.label, + description: categoryArgs.description, + }, + ] ); + mockSelect.getAbility = jest.fn().mockReturnValue( null ); + mockDispatch.mockClear(); + + // Register an ability using the new category + const ability: Ability = { + name: 'test/ability', + label: 'Test Ability', + description: 'Test description', + category: categorySlug, + callback: jest.fn(), + }; + + const abilityAction = registerAbility( ability ); + await abilityAction( { + select: mockSelect, + dispatch: mockDispatch, + } ); + + // Should successfully register with the new category + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY, + ability, + } ); + } ); + } ); + + describe( 'unregisterAbilityCategory', () => { + it( 'should create an action to unregister a category', () => { + const slug = 'test-category'; + const action = unregisterAbilityCategory( slug ); + + expect( action ).toEqual( { + type: UNREGISTER_ABILITY_CATEGORY, + slug, + } ); + } ); + + it( 'should handle valid category slugs', () => { + const slug = 'data-retrieval'; + const action = unregisterAbilityCategory( slug ); + + expect( action ).toEqual( { + type: UNREGISTER_ABILITY_CATEGORY, + slug, + } ); + } ); + } ); } ); diff --git a/packages/client/src/store/__tests__/reducer.test.ts b/packages/client/src/store/__tests__/reducer.test.ts index c1accb29..3b7de3f3 100644 --- a/packages/client/src/store/__tests__/reducer.test.ts +++ b/packages/client/src/store/__tests__/reducer.test.ts @@ -11,6 +11,8 @@ import { REGISTER_ABILITY, UNREGISTER_ABILITY, RECEIVE_CATEGORIES, + REGISTER_ABILITY_CATEGORY, + UNREGISTER_ABILITY_CATEGORY, } from '../constants'; describe( 'Store Reducer', () => { @@ -638,5 +640,220 @@ describe( 'Store Reducer', () => { expect( category ).not.toHaveProperty( 'meta' ); } ); } ); + + describe( 'REGISTER_ABILITY_CATEGORY', () => { + it( 'should add category to the state', () => { + const category = { + slug: 'test-category', + label: 'Test Category', + description: 'A test category', + }; + + const action = { + type: REGISTER_ABILITY_CATEGORY, + category, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toHaveProperty( + 'test-category' + ); + expect( state.categoriesBySlug[ 'test-category' ].label ).toBe( + 'Test Category' + ); + } ); + + it( 'should add category with meta to the state', () => { + const category = { + slug: 'test-category', + label: 'Test Category', + description: 'A test category', + meta: { color: 'blue', priority: 'high' }, + }; + + const action = { + type: REGISTER_ABILITY_CATEGORY, + category, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( + state.categoriesBySlug[ 'test-category' ].meta + ).toEqual( { color: 'blue', priority: 'high' } ); + } ); + + it( 'should filter out extra properties when registering', () => { + const category = { + slug: 'test-category', + label: 'Test Category', + description: 'A test category', + // Extra properties that should be filtered out + _links: { self: { href: '/test' } }, + _embedded: { author: { id: 1 } }, + extra_field: 'should be removed', + }; + + const action = { + type: REGISTER_ABILITY_CATEGORY, + category, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + const registeredCategory = + state.categoriesBySlug[ 'test-category' ]; + + // Should have valid properties + expect( registeredCategory.slug ).toBe( 'test-category' ); + expect( registeredCategory.label ).toBe( 'Test Category' ); + expect( registeredCategory.description ).toBe( + 'A test category' + ); + + // Should NOT have invalid properties + expect( registeredCategory ).not.toHaveProperty( '_links' ); + expect( registeredCategory ).not.toHaveProperty( '_embedded' ); + expect( registeredCategory ).not.toHaveProperty( + 'extra_field' + ); + } ); + + it( 'should replace existing category', () => { + const initialState = { + 'test-category': { + slug: 'test-category', + label: 'Old Label', + description: 'Old description', + }, + }; + + const category = { + slug: 'test-category', + label: 'New Label', + description: 'New description', + meta: { color: 'red' }, + }; + + const action = { + type: REGISTER_ABILITY_CATEGORY, + category, + }; + + const state = reducer( + { categoriesBySlug: initialState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug[ 'test-category' ].label ).toBe( + 'New Label' + ); + expect( + state.categoriesBySlug[ 'test-category' ].description + ).toBe( 'New description' ); + expect( + state.categoriesBySlug[ 'test-category' ].meta + ).toEqual( { color: 'red' } ); + } ); + + it( 'should handle undefined category', () => { + const action = { + type: REGISTER_ABILITY_CATEGORY, + category: undefined, + }; + + const state = reducer( + { categoriesBySlug: defaultState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toEqual( defaultState ); + } ); + } ); + + describe( 'UNREGISTER_ABILITY_CATEGORY', () => { + it( 'should remove category from the state', () => { + const initialState = { + category1: { + slug: 'category1', + label: 'Category 1', + description: 'First category', + }, + category2: { + slug: 'category2', + label: 'Category 2', + description: 'Second category', + }, + }; + + const action = { + type: UNREGISTER_ABILITY_CATEGORY, + slug: 'category1', + }; + + const state = reducer( + { categoriesBySlug: initialState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).not.toHaveProperty( + 'category1' + ); + expect( state.categoriesBySlug ).toHaveProperty( 'category2' ); + } ); + + it( 'should handle unregistering non-existent category', () => { + const initialState = { + 'test-category': { + slug: 'test-category', + label: 'Test Category', + description: 'A test category', + }, + }; + + const action = { + type: UNREGISTER_ABILITY_CATEGORY, + slug: 'non-existent', + }; + + const state = reducer( + { categoriesBySlug: initialState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toEqual( initialState ); + } ); + + it( 'should handle undefined slug', () => { + const initialState = { + 'test-category': { + slug: 'test-category', + label: 'Test Category', + description: 'A test category', + }, + }; + + const action = { + type: UNREGISTER_ABILITY_CATEGORY, + slug: undefined, + }; + + const state = reducer( + { categoriesBySlug: initialState, abilitiesByName: {} }, + action + ); + + expect( state.categoriesBySlug ).toEqual( initialState ); + } ); + } ); } ); } ); diff --git a/packages/client/src/store/actions.ts b/packages/client/src/store/actions.ts index 64e45ea5..3c15373c 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -7,12 +7,14 @@ import { resolveSelect } from '@wordpress/data'; /** * Internal dependencies */ -import type { Ability, AbilityCategory } from '../types'; +import type { Ability, AbilityCategory, AbilityCategoryArgs } from '../types'; import { RECEIVE_ABILITIES, REGISTER_ABILITY, UNREGISTER_ABILITY, RECEIVE_CATEGORIES, + REGISTER_ABILITY_CATEGORY, + UNREGISTER_ABILITY_CATEGORY, STORE_NAME, } from './constants'; @@ -156,3 +158,100 @@ export function unregisterAbility( name: string ) { name, }; } + +/** + * Registers a client-side ability category in the store. + * + * This action validates the category before registration. If validation fails, + * an error will be thrown. Categories will be automatically fetched from the + * REST API if they haven't been loaded yet to check for duplicates. + * + * @param slug The unique category slug identifier. + * @param args Category arguments (label, description, optional meta). + * @return Action object or function. + * @throws {Error} If validation fails. + */ +export function registerAbilityCategory( + slug: string, + args: AbilityCategoryArgs +) { + // @ts-expect-error - registry types are not yet available + return async ( { select, dispatch } ) => { + if ( ! slug ) { + throw new Error( 'Category slug is required' ); + } + + // Validate slug format matches server implementation + if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( slug ) ) { + throw new Error( + 'Category slug must contain only lowercase alphanumeric characters and dashes.' + ); + } + + // Ensure categories are loaded before checking for duplicates + let categories = select.getAbilityCategories(); + if ( categories.length === 0 ) { + await resolveSelect( STORE_NAME ).getAbilityCategories(); + categories = select.getAbilityCategories(); + } + + // Check if category is already registered (matches server order) + const existingCategory = select.getAbilityCategory( slug ); + if ( existingCategory ) { + throw new Error( + sprintf( 'Category "%s" is already registered.', slug ) + ); + } + + // Validate label presence and type (matches PHP empty() + is_string()) + if ( ! args.label || typeof args.label !== 'string' ) { + throw new Error( + 'The category properties must contain a `label` string.' + ); + } + + // Validate description presence and type (matches PHP empty() + is_string()) + if ( ! args.description || typeof args.description !== 'string' ) { + throw new Error( + 'The category properties must contain a `description` string.' + ); + } + + if ( args.meta !== undefined && typeof args.meta !== 'object' ) { + throw new Error( + 'The category properties should provide a valid `meta` array.' + ); + } + + if ( args.meta !== undefined && Array.isArray( args.meta ) ) { + throw new Error( + 'The category properties should provide a valid `meta` array.' + ); + } + + const category: AbilityCategory = { + slug, + label: args.label, + description: args.description, + ...( args.meta && { meta: args.meta } ), + }; + + dispatch( { + type: REGISTER_ABILITY_CATEGORY, + category, + } ); + }; +} + +/** + * Returns an action object used to unregister a client-side ability category. + * + * @param slug The slug of the category to unregister. + * @return Action object. + */ +export function unregisterAbilityCategory( slug: string ) { + return { + type: UNREGISTER_ABILITY_CATEGORY, + slug, + }; +} diff --git a/packages/client/src/store/constants.ts b/packages/client/src/store/constants.ts index a88286f4..c32d331d 100644 --- a/packages/client/src/store/constants.ts +++ b/packages/client/src/store/constants.ts @@ -11,3 +11,5 @@ export const RECEIVE_ABILITIES = 'RECEIVE_ABILITIES'; export const REGISTER_ABILITY = 'REGISTER_ABILITY'; export const UNREGISTER_ABILITY = 'UNREGISTER_ABILITY'; export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES'; +export const REGISTER_ABILITY_CATEGORY = 'REGISTER_ABILITY_CATEGORY'; +export const UNREGISTER_ABILITY_CATEGORY = 'UNREGISTER_ABILITY_CATEGORY'; diff --git a/packages/client/src/store/reducer.ts b/packages/client/src/store/reducer.ts index 9fee0ac8..89227fe3 100644 --- a/packages/client/src/store/reducer.ts +++ b/packages/client/src/store/reducer.ts @@ -12,6 +12,8 @@ import { REGISTER_ABILITY, UNREGISTER_ABILITY, RECEIVE_CATEGORIES, + REGISTER_ABILITY_CATEGORY, + UNREGISTER_ABILITY_CATEGORY, } from './constants'; /** @@ -81,7 +83,9 @@ interface AbilitiesAction { abilities?: Ability[]; ability?: Ability; categories?: AbilityCategory[]; + category?: AbilityCategory; name?: string; + slug?: string; } const DEFAULT_STATE: Record< string, Ability > = {}; @@ -154,6 +158,23 @@ function categoriesBySlug( } ); return newState; } + case REGISTER_ABILITY_CATEGORY: { + if ( ! action.category ) { + return state; + } + return { + ...state, + [ action.category.slug ]: filterCategory( action.category ), + }; + } + case UNREGISTER_ABILITY_CATEGORY: { + if ( ! action.slug || ! state[ action.slug ] ) { + return state; + } + const newState = { ...state }; + delete newState[ action.slug ]; + return newState; + } default: return state; } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 1b093801..934204cb 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -134,6 +134,29 @@ export interface AbilityCategory { meta?: Record< string, any >; } +/** + * Arguments for registering an ability category. + * Matches the server-side wp_register_ability_category() $args parameter. + * + * @see wp_register_ability_category() + */ +export interface AbilityCategoryArgs { + /** + * The human-readable label for the category. + */ + label: string; + + /** + * The detailed description of the category. + */ + description: string; + + /** + * Optional metadata about the category. + */ + meta?: Record< string, any >; +} + /** * The state shape for the abilities store. */ From da86104662ef10c0a3f11bed8574c74f58b6921c Mon Sep 17 00:00:00 2001 From: Em Date: Thu, 16 Oct 2025 12:52:31 -0400 Subject: [PATCH 5/6] fix: use resolveSelect and use createSelector for getAbilityCategories --- .../src/store/__tests__/actions.test.ts | 119 +++++++++++------- packages/client/src/store/actions.ts | 16 +-- packages/client/src/store/selectors.ts | 11 +- 3 files changed, 82 insertions(+), 64 deletions(-) diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index b6cf58aa..5c67c22f 100644 --- a/packages/client/src/store/__tests__/actions.test.ts +++ b/packages/client/src/store/__tests__/actions.test.ts @@ -84,20 +84,24 @@ describe( 'Store Actions', () => { beforeEach( () => { jest.clearAllMocks(); + const defaultCategories = [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Abilities that retrieve data', + }, + ]; + mockSelect = { getAbility: jest.fn().mockReturnValue( null ), - getAbilityCategories: jest.fn().mockReturnValue( [ - { - slug: 'test-category', - label: 'Test Category', - description: 'Test category for testing', - }, - { - slug: 'data-retrieval', - label: 'Data Retrieval', - description: 'Abilities that retrieve data', - }, - ] ), + getAbilityCategories: jest + .fn() + .mockReturnValue( defaultCategories ), getAbilityCategory: jest.fn().mockImplementation( ( slug ) => { const categories: Record< string, any > = { 'test-category': { @@ -115,6 +119,13 @@ describe( 'Store Actions', () => { } ), }; mockDispatch = jest.fn(); + + // Mock resolveSelect to return categories + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest + .fn() + .mockResolvedValue( defaultCategories ), + } ); } ); it( 'should register a valid client ability', async () => { @@ -316,7 +327,7 @@ describe( 'Store Actions', () => { callback: jest.fn(), }; - mockSelect.getAbilityCategories.mockReturnValue( [ + const categoriesForTest = [ { slug: 'test-category', label: 'Test Category', @@ -332,7 +343,17 @@ describe( 'Store Actions', () => { label: 'Test Category', description: 'Test', }, - ] ); + ]; + + // Mock both select and resolveSelect + mockSelect.getAbilityCategories.mockReturnValue( + categoriesForTest + ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest + .fn() + .mockResolvedValue( categoriesForTest ), + } ); mockSelect.getAbility.mockReturnValue( null ); mockDispatch.mockClear(); @@ -380,7 +401,7 @@ describe( 'Store Actions', () => { } ); it( 'should accept ability with existing category', async () => { - mockSelect.getAbilityCategories.mockReturnValue( [ + const categoriesForTest = [ { slug: 'test-category', label: 'Test Category', @@ -391,7 +412,16 @@ describe( 'Store Actions', () => { label: 'Data Retrieval', description: 'Abilities that retrieve data', }, - ] ); + ]; + + mockSelect.getAbilityCategories.mockReturnValue( + categoriesForTest + ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest + .fn() + .mockResolvedValue( categoriesForTest ), + } ); const ability: Ability = { name: 'test/ability', @@ -404,8 +434,10 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); await action( { select: mockSelect, dispatch: mockDispatch } ); - // We check the categories array now, not calling getAbilityCategory - expect( mockSelect.getAbilityCategories ).toHaveBeenCalled(); + // resolveSelect should have been called to load categories + expect( resolveSelect ).toHaveBeenCalledWith( + 'abilities-api/abilities' + ); expect( mockDispatch ).toHaveBeenCalledWith( { type: REGISTER_ABILITY, ability, @@ -457,30 +489,19 @@ describe( 'Store Actions', () => { expect( mockDispatch ).not.toHaveBeenCalled(); } ); - it( 'should load categories when store is empty before validation', async () => { - // First call returns empty, second call returns loaded categories - let callCount = 0; - mockSelect.getAbilityCategories.mockImplementation( () => { - callCount++; - if ( callCount === 1 ) { - return []; // Empty on first call - } - return [ - { - slug: 'test-category', - label: 'Test Category', - description: 'Test category', - }, - ]; // Loaded on second call - } ); + it( 'should load categories using resolveSelect before validation', async () => { + const categories = [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category', + }, + ]; - // Mock resolveSelect to return a mock that resolves the getAbilityCategories call - const mockResolveSelectFn = jest.fn().mockReturnValue( { - getAbilityCategories: jest.fn().mockResolvedValue( undefined ), + // Mock resolveSelect to return categories directly + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest.fn().mockResolvedValue( categories ), } ); - ( resolveSelect as jest.Mock ).mockImplementation( - mockResolveSelectFn - ); const ability: Ability = { name: 'test/ability', @@ -493,9 +514,9 @@ describe( 'Store Actions', () => { const action = registerAbility( ability ); await action( { select: mockSelect, dispatch: mockDispatch } ); - // Should have called getAbilityCategories twice (once to check, once after loading) - expect( mockSelect.getAbilityCategories ).toHaveBeenCalledTimes( - 2 + // Should have called resolveSelect to load categories + expect( resolveSelect ).toHaveBeenCalledWith( + 'abilities-api/abilities' ); // Should have successfully registered expect( mockDispatch ).toHaveBeenCalledWith( { @@ -856,13 +877,21 @@ describe( 'Store Actions', () => { } ); // Now mock that the category exists for ability registration - mockSelect.getAbilityCategories = jest.fn().mockReturnValue( [ + const categoriesWithNew = [ { slug: categorySlug, label: categoryArgs.label, description: categoryArgs.description, }, - ] ); + ]; + mockSelect.getAbilityCategories = jest + .fn() + .mockReturnValue( categoriesWithNew ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest + .fn() + .mockResolvedValue( categoriesWithNew ), + } ); mockSelect.getAbility = jest.fn().mockReturnValue( null ); mockDispatch.mockClear(); diff --git a/packages/client/src/store/actions.ts b/packages/client/src/store/actions.ts index 3c15373c..11a42182 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -99,14 +99,8 @@ export function registerAbility( ability: Ability ) { } // Ensure categories are loaded before validating - let categories = select.getAbilityCategories(); - if ( categories.length === 0 ) { + const categories = await resolveSelect( STORE_NAME ).getAbilityCategories(); - categories = select.getAbilityCategories(); - } - - // Validate that the category exists in the loaded categories - // We check the array directly to avoid triggering the single-category resolver const existingCategory = categories.find( ( cat: AbilityCategory ) => cat.slug === ability.category ); @@ -189,13 +183,7 @@ export function registerAbilityCategory( } // Ensure categories are loaded before checking for duplicates - let categories = select.getAbilityCategories(); - if ( categories.length === 0 ) { - await resolveSelect( STORE_NAME ).getAbilityCategories(); - categories = select.getAbilityCategories(); - } - - // Check if category is already registered (matches server order) + await resolveSelect( STORE_NAME ).getAbilityCategories(); const existingCategory = select.getAbilityCategory( slug ); if ( existingCategory ) { throw new Error( diff --git a/packages/client/src/store/selectors.ts b/packages/client/src/store/selectors.ts index d497de36..dc191d9e 100644 --- a/packages/client/src/store/selectors.ts +++ b/packages/client/src/store/selectors.ts @@ -60,11 +60,12 @@ export function getAbility( * @param state Store state. * @return Array of categories. */ -export function getAbilityCategories( - state: AbilitiesState -): AbilityCategory[] { - return Object.values( state.categoriesBySlug ); -} +export const getAbilityCategories = createSelector( + ( state: AbilitiesState ): AbilityCategory[] => { + return Object.values( state.categoriesBySlug ); + }, + ( state: AbilitiesState ) => [ state.categoriesBySlug ] +); /** * Returns a specific ability category by slug. From 91d8bf8fd7131bedb38400a2f2199291c72d6442 Mon Sep 17 00:00:00 2001 From: Em Date: Thu, 16 Oct 2025 17:31:06 -0400 Subject: [PATCH 6/6] fix: update docs/ on client, clarify selector call, and fix for loading from server if a client category is registered --- docs/7.javascript-client.md | 109 +++++++++++++++++- .../src/store/__tests__/actions.test.ts | 14 ++- .../src/store/__tests__/resolvers.test.ts | 62 ++++++++++ packages/client/src/store/actions.ts | 7 +- packages/client/src/store/resolvers.ts | 15 ++- 5 files changed, 201 insertions(+), 6 deletions(-) diff --git a/docs/7.javascript-client.md b/docs/7.javascript-client.md index 7de40a96..9423a64b 100644 --- a/docs/7.javascript-client.md +++ b/docs/7.javascript-client.md @@ -64,6 +64,46 @@ if ( ability ) { } ``` +### `getAbilityCategories()` + +Returns an array of all registered ability categories. + +**Parameters:** None + +**Returns:** `Promise` - Array of category objects + +**Example:** + +```javascript +const categories = await getAbilityCategories(); +console.log( `Found ${ categories.length } categories` ); + +// List all categories +categories.forEach( ( category ) => { + console.log( `${ category.label }: ${ category.description }` ); +} ); +``` + +### `getAbilityCategory( slug )` + +Retrieves a specific category by slug. + +**Parameters:** + +- `slug` (string) - The category slug (e.g., 'data-retrieval') + +**Returns:** `Promise` - The category object or null if not found + +**Example:** + +```javascript +const category = await getAbilityCategory( 'data-retrieval' ); +if ( category ) { + console.log( 'Label:', category.label ); + console.log( 'Description:', category.description ); +} +``` + ### `executeAbility( name, input = null )` Executes an ability with the provided input data. @@ -98,7 +138,7 @@ Registers a client-side ability that runs in the browser. - `ability` (object) - The ability configuration object -**Returns:** `void` +**Returns:** `Promise` **Example:** @@ -110,7 +150,7 @@ const showNotification = ( message ) => { }; // Register a notification ability which calls the showNotification function -registerAbility( { +await registerAbility( { name: 'my-plugin/show-notification', label: 'Show Notification', description: 'Display a notification message to the user', @@ -160,6 +200,8 @@ Removes a previously registered client-side ability. - `name` (string) - The ability name to unregister +**Returns:** `void` + **Example:** ```javascript @@ -167,6 +209,69 @@ Removes a previously registered client-side ability. unregisterAbility( 'my-plugin/old-ability' ); ``` +### `registerAbilityCategory( slug, args )` + +Registers a client-side ability category. This is useful when registering client-side abilities that introduce new categories not defined by the server. + +**Parameters:** + +- `slug` (string) - The category slug (lowercase alphanumeric with dashes only) +- `args` (object) - Category configuration object + - `label` (string) - Human-readable label for the category + - `description` (string) - Detailed description of the category + - `meta` (object, optional) - Optional metadata about the category + +**Returns:** `Promise` + +**Example:** + +```javascript +// Register a new category +await registerAbilityCategory( 'block-editor', { + label: 'Block Editor', + description: 'Abilities for interacting with the WordPress block editor', +} ); + +// Register a category with metadata +await registerAbilityCategory( 'custom-category', { + label: 'Custom Category', + description: 'A category for custom abilities', + meta: { + priority: 'high', + icon: 'dashicons-admin-customizer', + }, +} ); + +// Then register abilities using the new category +await registerAbility( { + name: 'my-plugin/insert-block', + label: 'Insert Block', + description: 'Inserts a block into the editor', + category: 'block-editor', // Uses the client-registered category + callback: async ( { blockType } ) => { + // Implementation + return { success: true }; + }, +} ); +``` + +### `unregisterAbilityCategory( slug )` + +Removes a previously registered client-side category. + +**Parameters:** + +- `slug` (string) - The category slug to unregister + +**Returns:** `void` + +**Example:** + +```javascript +// Unregister a category +unregisterAbilityCategory( 'block-editor' ); +``` + ## Error Handling All functions return promises that may reject with specific error codes: diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index 5c67c22f..cc759a2a 100644 --- a/packages/client/src/store/__tests__/actions.test.ts +++ b/packages/client/src/store/__tests__/actions.test.ts @@ -616,6 +616,9 @@ describe( 'Store Actions', () => { slug, label: args.label, description: args.description, + meta: { + _clientRegistered: true, + }, }, } ); } ); @@ -637,7 +640,10 @@ describe( 'Store Actions', () => { slug, label: args.label, description: args.description, - meta: args.meta, + meta: { + ...args.meta, + _clientRegistered: true, + }, }, } ); } ); @@ -716,6 +722,9 @@ describe( 'Store Actions', () => { slug: validSlug, label: args.label, description: args.description, + meta: { + _clientRegistered: true, + }, }, } ); } @@ -873,6 +882,9 @@ describe( 'Store Actions', () => { slug: categorySlug, label: categoryArgs.label, description: categoryArgs.description, + meta: { + _clientRegistered: true, + }, }, } ); diff --git a/packages/client/src/store/__tests__/resolvers.test.ts b/packages/client/src/store/__tests__/resolvers.test.ts index 74026eff..cbcbd808 100644 --- a/packages/client/src/store/__tests__/resolvers.test.ts +++ b/packages/client/src/store/__tests__/resolvers.test.ts @@ -430,6 +430,68 @@ describe( 'Store Resolvers', () => { expect( mockDispatch ).not.toHaveBeenCalled(); } ); + it( 'should fetch from server even when only client-registered categories exist', async () => { + // This tests the scenario where a client category is registered first + // The resolver should still fetch server categories + const clientOnlyCategories: AbilityCategory[] = [ + { + slug: 'client-category', + label: 'Client Category', + description: 'A category registered on the client', + meta: { + _clientRegistered: true, + }, + }, + ]; + + const serverCategories: AbilityCategory[] = [ + { + slug: 'data-retrieval', + label: 'Data Retrieval', + description: 'Server category', + }, + { + slug: 'user-management', + label: 'User Management', + description: 'Another server category', + }, + ]; + + const mockResolveSelect = { + getEntityRecords: jest + .fn() + .mockResolvedValue( serverCategories ), + }; + + const mockSelectInstance = { + getAbilityCategories: jest + .fn() + .mockReturnValue( clientOnlyCategories ), + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + + const resolver = getAbilityCategories(); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelectInstance, + } ); + + // Should fetch from server because only client categories exist + expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith( + coreStore + ); + expect( mockResolveSelect.getEntityRecords ).toHaveBeenCalledWith( + ENTITY_KIND, + ENTITY_NAME_CATEGORIES, + { per_page: -1 } + ); + expect( mockDispatch ).toHaveBeenCalledWith( + receiveCategories( serverCategories ) + ); + } ); + it( 'should handle null response', async () => { const mockResolveSelect = { getEntityRecords: jest.fn().mockResolvedValue( null ), diff --git a/packages/client/src/store/actions.ts b/packages/client/src/store/actions.ts index 11a42182..8b156ba5 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -221,7 +221,12 @@ export function registerAbilityCategory( slug, label: args.label, description: args.description, - ...( args.meta && { meta: args.meta } ), + meta: { + ...( args.meta || {} ), + // Internal implementation note: Client-registered categories will have `meta._clientRegistered` set to `true` to differentiate them from server-fetched categories. + // This is used internally by the resolver to determine whether to fetch categories from the server. + _clientRegistered: true, + }, }; dispatch( { diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index a328c96e..a932b803 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -6,7 +6,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import type { Ability } from '../types'; +import type { Ability, AbilityCategory } from '../types'; import { ENTITY_KIND, ENTITY_NAME, ENTITY_NAME_CATEGORIES } from './constants'; import { receiveAbilities, receiveCategories } from './actions'; @@ -85,7 +85,15 @@ export function getAbilityCategories() { // @ts-expect-error - registry types are not yet available return async ( { dispatch, registry, select } ) => { const existingCategories = select.getAbilityCategories(); - if ( existingCategories && existingCategories.length > 0 ) { + + // Check if we have any server-side categories (categories without meta._clientRegistered flag) + // Client categories have meta._clientRegistered=true and might be registered immediately + // We only want to skip fetching if we've already fetched server categories + const hasServerCategories = existingCategories.some( + ( category: AbilityCategory ) => ! category.meta?._clientRegistered + ); + + if ( hasServerCategories ) { return; } @@ -108,6 +116,9 @@ export function getAbilityCategories() { export function getAbilityCategory( slug: string ) { // @ts-expect-error - registry types are not yet available return async ( { dispatch, registry, select } ) => { + // Check if category already exists in store (either client-registered or server-fetched). + // This prevents unnecessary network requests while allowing client-side categories + // to be retrieved immediately without hitting the API. const existingCategory = select.getAbilityCategory( slug ); if ( existingCategory ) { return;