diff --git a/docs/7.javascript-client.md b/docs/7.javascript-client.md index 6b3b3a4..7de40a9 100644 --- a/docs/7.javascript-client.md +++ b/docs/7.javascript-client.md @@ -14,11 +14,13 @@ You can read more about installation and setup in the [package readme](../packag ## Core API Functions -### getAbilities() +### `getAbilities( args = {} )` Returns an array of all registered abilities (both server-side and client-side). -**Parameters:** None +**Parameters:** `args` (object, optional) - Query arguments to filter abilities. Supported arguments: + +- `category` (string) - Filter abilities by category slug **Returns:** `Promise` - Array of ability objects @@ -34,13 +36,19 @@ console.log(`Found ${abilities.length} abilities`); abilities.forEach(ability => { console.log(`${ability.name}: ${ability.description}`); }); + +// Get abilities in a specific category +const dataAbilities = await getAbilities( { category: 'data-retrieval' } ); + +console.log( `Found ${ dataAbilities.length } data retrieval abilities` ); ``` -### getAbility(name) +### getAbility( name ) Retrieves a specific ability by name. **Parameters:** + - `name` (string) - The ability name (e.g., 'my-plugin/get-posts') **Returns:** `Promise` - The ability object or null if not found @@ -48,19 +56,20 @@ Retrieves a specific ability by name. **Example:** ```javascript -const ability = await getAbility('my-plugin/get-site-info'); -if (ability) { - console.log('Label:', ability.label); - console.log('Description:', ability.description); - console.log('Input Schema:', ability.input_schema); +const ability = await getAbility( 'my-plugin/get-site-info' ); +if ( ability ) { + console.log( 'Label:', ability.label ); + console.log( 'Description:', ability.description ); + console.log( 'Input Schema:', ability.input_schema ); } ``` -### executeAbility(name, input) +### `executeAbility( name, input = null )` Executes an ability with the provided input data. **Parameters:** + - `name` (string) - The ability name - `input` (any, optional) - Input data for the ability @@ -70,22 +79,23 @@ Executes an ability with the provided input data. ```javascript // Execute without input -const siteTitle = await executeAbility('my-plugin/get-site-title'); -console.log('Site:', siteTitle); +const siteTitle = await executeAbility( 'my-plugin/get-site-title' ); +console.log( 'Site:', siteTitle ); // Execute with input parameters -const posts = await executeAbility('my-plugin/get-posts', { - category: 'news', - limit: 5 -}); -posts.forEach(post => console.log(post.title)); +const posts = await executeAbility( 'my-plugin/get-posts', { + category: 'news', + limit: 5, +} ); +posts.forEach( ( post ) => console.log( post.title ) ); ``` -### registerAbility(ability) +### `registerAbility( ability )` Registers a client-side ability that runs in the browser. **Parameters:** + - `ability` (object) - The ability configuration object **Returns:** `void` @@ -94,63 +104,67 @@ Registers a client-side ability that runs in the browser. ```javascript // showNotification function -const showNotification = (message) => { - new Notification(message); +const showNotification = ( message ) => { + new Notification( message ); return { success: true, displayed: message }; -} +}; // Register a notification ability which calls the showNotification function -registerAbility({ - name: 'my-plugin/show-notification', - label: 'Show Notification', - description: 'Display a notification message to the user', - input_schema: { - type: 'object', - properties: { - message: { type: 'string' }, - type: { type: 'string', enum: ['success', 'error', 'warning', 'info'] } - }, - required: ['message'] - }, - callback: async ({ message, type = 'info' }) => { - // Show browser notification - if (!("Notification" in window)) { - alert("This browser does not support desktop notification"); - return { success: false, error: 'Browser does not support notifications' }; - } - if (Notification.permission !== 'granted') { - Notification.requestPermission().then((permission) => { - if (permission === "granted") { - return showNotification(message); - } - }); - } - return showNotification(message); +registerAbility( { + name: 'my-plugin/show-notification', + label: 'Show Notification', + description: 'Display a notification message to the user', + input_schema: { + type: 'object', + properties: { + message: { type: 'string' }, + type: { type: 'string', enum: [ 'success', 'error', 'warning', 'info' ] }, }, - permissionCallback: () => { - return !!wp.data.select('core').getCurrentUser(); + required: [ 'message' ], + }, + callback: async ( { message, type = 'info' } ) => { + // Show browser notification + if ( ! ( 'Notification' in window ) ) { + alert( 'This browser does not support desktop notification' ); + return { + success: false, + error: 'Browser does not support notifications', + }; } -}); + if ( Notification.permission !== 'granted' ) { + Notification.requestPermission().then( ( permission ) => { + if ( permission === 'granted' ) { + return showNotification( message ); + } + } ); + } + return showNotification( message ); + }, + permissionCallback: () => { + return !! wp.data.select( 'core' ).getCurrentUser(); + }, +} ); // Use the registered ability -const result = await executeAbility('my-plugin/show-notification', { - message: 'Hello World!', - type: 'success' -}); +const result = await executeAbility( 'my-plugin/show-notification', { + message: 'Hello World!', + type: 'success', +} ); ``` -### unregisterAbility(name) +### `unregisterAbility( name )` Removes a previously registered client-side ability. **Parameters:** + - `name` (string) - The ability name to unregister **Example:** ```javascript // Unregister an ability -unregisterAbility('my-plugin/old-ability'); +unregisterAbility( 'my-plugin/old-ability' ); ``` ## Error Handling @@ -159,21 +173,21 @@ All functions return promises that may reject with specific error codes: ```javascript try { - const result = await executeAbility('my-plugin/restricted-action', input); - console.log('Success:', result); -} catch (error) { - switch (error.code) { - case 'ability_permission_denied': - console.error('Permission denied:', error.message); - break; - case 'ability_invalid_input': - console.error('Invalid input:', error.message); - break; - case 'rest_ability_not_found': - console.error('Ability not found:', error.message); - break; - default: - console.error('Execution failed:', error.message); - } + const result = await executeAbility( 'my-plugin/restricted-action', input ); + console.log( 'Success:', result ); +} catch ( error ) { + switch ( error.code ) { + case 'ability_permission_denied': + console.error( 'Permission denied:', error.message ); + break; + case 'ability_invalid_input': + console.error( 'Invalid input:', error.message ); + break; + case 'rest_ability_not_found': + console.error( 'Ability not found:', error.message ); + break; + default: + console.error( 'Execution failed:', error.message ); + } } ``` diff --git a/packages/client/README.md b/packages/client/README.md index 6da54bc..4588d2e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -75,16 +75,21 @@ function MyComponent() { ### Functions -#### `getAbilities(): Promise` +#### `getAbilities( args: AbilitiesQueryArgs = {} ): Promise` -Returns all registered abilities. Automatically handles pagination to fetch all abilities across multiple pages if needed. +Returns all registered abilities. Optionally filter by category slug. Automatically handles pagination to fetch all abilities across multiple pages if needed. ```javascript +// Get all abilities const abilities = await getAbilities(); console.log( `Found ${ abilities.length } abilities` ); + +// Get abilities in a specific category +const dataAbilities = await getAbilities( { category: 'data-retrieval' } ); +console.log( `Found ${ dataAbilities.length } data retrieval abilities` ); ``` -#### `getAbility(name: string): Promise` +#### `getAbility( name: string ): Promise` Returns a specific ability by name, or null if not found. @@ -95,7 +100,33 @@ if ( ability ) { } ``` -#### `executeAbility(name: string, input?: Record): Promise` +#### `registerAbility( ability: Ability ): void` + +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( { + name: 'my-plugin/navigate', + label: 'Navigate to URL', + description: 'Navigates to a URL within WordPress admin', + category: 'navigation', + input_schema: { + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: [ 'url' ], + }, + callback: async ( { url } ) => { + window.location.href = url; + return { success: true }; + }, +} ); +``` + +#### `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: @@ -119,8 +150,40 @@ const result = await executeAbility( 'my-plugin/create-item', { When using with `@wordpress/data`: -- `getAbilities()` - Returns all abilities from the store -- `getAbility(name)` - Returns a specific ability from the store +- `getAbilities( args: AbilitiesQueryArgs = {} )` - Returns all abilities from the store, optionally filtered by query arguments +- `getAbility( name: string )` - Returns a specific ability from the store + +```javascript +import { useSelect } from '@wordpress/data'; +import { store as abilitiesStore } from '@wordpress/abilities'; + +function MyComponent() { + // Get all abilities + const allAbilities = useSelect( + ( select ) => select( abilitiesStore ).getAbilities(), + [] + ); + + // Get abilities in a specific category + const dataAbilities = useSelect( + ( select ) => + select( abilitiesStore ).getAbilities( { category: 'data-retrieval' } ), + [] + ); + + return ( +
+

All Abilities ({ allAbilities.length })

+

Data Retrieval Abilities

+
    + { dataAbilities.map( ( ability ) => ( +
  • { ability.label }
  • + ) ) } +
+
+ ); +} +``` ## Development and Testing diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index 1455328..9a0c874 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -46,6 +46,7 @@ describe( 'API functions', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'First test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -53,6 +54,7 @@ describe( 'API functions', () => { name: 'test/ability2', label: 'Test Ability 2', description: 'Second test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -71,6 +73,40 @@ describe( 'API functions', () => { expect( mockGetAbilities ).toHaveBeenCalled(); expect( result ).toEqual( mockAbilities ); } ); + + it( 'should pass category parameter to store when filtering', async () => { + const mockAbilities: Ability[] = [ + { + name: 'test/ability1', + label: 'Test Ability 1', + description: 'First test ability', + category: 'data-retrieval', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + { + name: 'test/ability2', + label: 'Test Ability 2', + description: 'Second test ability', + category: 'data-retrieval', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + ]; + + const mockGetAbilities = jest + .fn() + .mockResolvedValue( mockAbilities ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilities: mockGetAbilities, + } ); + + const result = await getAbilities( { category: 'data-retrieval' } ); + + expect( resolveSelect ).toHaveBeenCalledWith( store ); + expect( mockGetAbilities ).toHaveBeenCalledWith( { category: 'data-retrieval' } ); + expect( result ).toEqual( mockAbilities ); + } ); } ); describe( 'getAbility', () => { @@ -79,6 +115,7 @@ describe( 'API functions', () => { name: 'test/ability', label: 'Test Ability', description: 'Test ability description', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; @@ -124,6 +161,7 @@ describe( 'API functions', () => { name: 'test/client-ability', label: 'Client Ability', description: 'Test client ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, callback: jest.fn(), @@ -158,6 +196,7 @@ describe( 'API functions', () => { name: 'test/server-ability', label: 'Server Ability', description: 'Test server ability', + category: 'test-category', input_schema: { type: 'object', properties: { @@ -200,6 +239,7 @@ describe( 'API functions', () => { name: 'test/client-ability', label: 'Client Ability', description: 'Test client ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, callback: mockCallback, @@ -238,6 +278,7 @@ describe( 'API functions', () => { name: 'test/client-ability', label: 'Client Ability', description: 'Test client ability', + category: 'test-category', input_schema: { type: 'object', properties: { @@ -264,6 +305,7 @@ describe( 'API functions', () => { name: 'test/read-only', label: 'Read-only Ability', description: 'Test read-only ability.', + category: 'test-category', input_schema: { type: 'object', properties: { @@ -302,11 +344,12 @@ describe( 'API functions', () => { name: 'test/read-only', label: 'Read-only Ability', description: 'Test read-only ability.', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, meta: { annotations: { readonly: true }, - } + }, }; const mockGetAbility = jest.fn().mockResolvedValue( mockAbility ); @@ -339,6 +382,7 @@ describe( 'API functions', () => { name: 'test/client-ability', label: 'Client Ability', description: 'Test client ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, callback: mockCallback, @@ -371,6 +415,7 @@ describe( 'API functions', () => { name: 'test/server-ability', label: 'Server Ability', description: 'Test server ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; @@ -399,6 +444,7 @@ describe( 'API functions', () => { name: 'test/ability', label: 'Test Ability', description: 'Test ability without callback', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, // No callback - should execute as server ability @@ -434,6 +480,7 @@ describe( 'API functions', () => { name: 'test/client-ability', label: 'Client Ability', description: 'Test client ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object', diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index d56471d..2032fdf 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -10,16 +10,17 @@ import { sprintf } from '@wordpress/i18n'; * Internal dependencies */ import { store } from './store'; -import type { Ability, AbilityInput, AbilityOutput } from './types'; +import type { Ability, AbilitiesQueryArgs, AbilityInput, AbilityOutput } from './types'; import { validateValueFromSchema } from './validation'; /** - * Get all available abilities. + * Get all available abilities with optional filtering. * + * @param args Optional query arguments to filter. Defaults to empty object. * @return Promise resolving to array of abilities. */ -export async function getAbilities(): Promise< Ability[] > { - return await resolveSelect( store ).getAbilities(); +export async function getAbilities( args: AbilitiesQueryArgs = {} ): Promise< Ability[] > { + return await resolveSelect( store ).getAbilities( args ); } /** diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index 3ee5cac..c2fe76e 100644 --- a/packages/client/src/store/__tests__/actions.test.ts +++ b/packages/client/src/store/__tests__/actions.test.ts @@ -25,6 +25,7 @@ describe( 'Store Actions', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'First test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -32,6 +33,7 @@ describe( 'Store Actions', () => { name: 'test/ability2', label: 'Test Ability 2', description: 'Second test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -73,6 +75,7 @@ describe( 'Store Actions', () => { name: 'test/ability', label: 'Test Ability', description: 'Test ability description', + category: 'test-category', input_schema: { type: 'object', properties: { @@ -102,6 +105,7 @@ describe( 'Store Actions', () => { name: 'test/server-ability', label: 'Server Ability', description: 'Server-side ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; @@ -120,6 +124,7 @@ describe( 'Store Actions', () => { name: '', label: 'Test Ability', description: 'Test description', + category: 'test-category', callback: jest.fn(), }; @@ -146,6 +151,7 @@ describe( 'Store Actions', () => { name: invalidName, label: 'Test Ability', description: 'Test description', + category: 'test-category', callback: jest.fn(), }; @@ -165,6 +171,7 @@ describe( 'Store Actions', () => { name: 'test/ability', label: '', description: 'Test description', + category: 'test-category', callback: jest.fn(), }; @@ -181,6 +188,7 @@ describe( 'Store Actions', () => { name: 'test/ability', label: 'Test Ability', description: '', + category: 'test-category', callback: jest.fn(), }; @@ -192,11 +200,90 @@ describe( 'Store Actions', () => { expect( mockDispatch ).not.toHaveBeenCalled(); } ); + it( 'should validate and reject ability without category', () => { + const ability: Ability = { + name: 'test/ability', + label: 'Test Ability', + description: 'Test description', + category: '', + callback: jest.fn(), + }; + + const action = registerAbility( ability ); + + expect( () => + action( { select: mockSelect, dispatch: mockDispatch } ) + ).toThrow( 'Ability "test/ability" must have a category' ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should validate and reject ability with invalid category format', () => { + const testCases = [ + 'Data-Retrieval', // Uppercase letters + 'data_retrieval', // Underscores not allowed + 'data.retrieval', // Dots not allowed + 'data/retrieval', // Slashes not allowed + '-data-retrieval', // Leading dash + 'data-retrieval-', // Trailing dash + 'data--retrieval', // Double dash + ]; + + testCases.forEach( ( invalidCategory ) => { + const ability: Ability = { + name: 'test/ability', + label: 'Test Ability', + description: 'Test description', + category: invalidCategory, + callback: jest.fn(), + }; + + const action = registerAbility( ability ); + + expect( () => + action( { select: mockSelect, dispatch: mockDispatch } ) + ).toThrow( + 'Ability "test/ability" has an invalid category. Category must be lowercase alphanumeric with dashes only' + ); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + } ); + + it( 'should accept ability with valid category format', () => { + const validCategories = [ + 'data-retrieval', + 'user-management', + 'analytics-123', + 'ecommerce', + ]; + + validCategories.forEach( ( validCategory ) => { + const ability: Ability = { + name: 'test/ability-' + validCategory, + label: 'Test Ability', + description: 'Test description', + category: validCategory, + callback: jest.fn(), + }; + + mockSelect.getAbility.mockReturnValue( null ); + mockDispatch.mockClear(); + + const action = registerAbility( ability ); + action( { select: mockSelect, dispatch: mockDispatch } ); + + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY, + ability, + } ); + } ); + } ); + it( 'should validate and reject ability with invalid callback', () => { const ability: Ability = { name: 'test/ability', label: 'Test Ability', description: 'Test description', + category: 'test-category', callback: 'not a function' as any, }; @@ -215,6 +302,7 @@ describe( 'Store Actions', () => { name: 'test/ability', label: 'Existing Ability', description: 'Already registered', + category: 'test-category', }; mockSelect.getAbility.mockReturnValue( existingAbility ); @@ -223,6 +311,7 @@ describe( 'Store Actions', () => { name: 'test/ability', label: 'Test Ability', description: 'Test description', + category: 'test-category', callback: jest.fn(), }; diff --git a/packages/client/src/store/__tests__/resolvers.test.ts b/packages/client/src/store/__tests__/resolvers.test.ts index 968804e..536fbfa 100644 --- a/packages/client/src/store/__tests__/resolvers.test.ts +++ b/packages/client/src/store/__tests__/resolvers.test.ts @@ -41,6 +41,7 @@ describe( 'Store Resolvers', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'First test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -51,16 +52,16 @@ describe( 'Store Resolvers', () => { }; const mockSelectInstance = { - getEntityRecordsTotalPages: jest.fn().mockReturnValue( 1 ), + getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty }; mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); - mockRegistry.select.mockReturnValue( mockSelectInstance ); const resolver = getAbilities(); await resolver( { dispatch: mockDispatch, registry: mockRegistry, + select: mockSelectInstance, } ); expect( mockRegistry.resolveSelect ).toHaveBeenCalledWith( @@ -76,17 +77,56 @@ describe( 'Store Resolvers', () => { ); } ); + it( 'should not fetch if store already has abilities', async () => { + const existingAbilities: Ability[] = [ + { + name: 'test/ability1', + label: 'Test Ability 1', + description: 'First test ability', + category: 'data-retrieval', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + ]; + + const mockResolveSelect = { + getEntityRecords: jest.fn(), + }; + + const mockSelectInstance = { + getAbilities: jest.fn().mockReturnValue( existingAbilities ), // Store has data + }; + + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); + + const resolver = getAbilities( { category: 'data-retrieval' } ); + await resolver( { + dispatch: mockDispatch, + registry: mockRegistry, + select: mockSelectInstance, + } ); + + // Should not fetch since store already has abilities + expect( mockResolveSelect.getEntityRecords ).not.toHaveBeenCalled(); + expect( mockDispatch ).not.toHaveBeenCalled(); + } ); + it( 'should handle empty abilities', async () => { const mockResolveSelect = { getEntityRecords: jest.fn().mockResolvedValue( [] ), }; + const mockSelectInstance = { + getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty + }; + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); const resolver = getAbilities(); await resolver( { dispatch: mockDispatch, registry: mockRegistry, + select: mockSelectInstance, } ); expect( mockDispatch ).toHaveBeenCalledWith( @@ -99,12 +139,17 @@ describe( 'Store Resolvers', () => { getEntityRecords: jest.fn().mockResolvedValue( null ), }; + const mockSelectInstance = { + getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty + }; + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); const resolver = getAbilities(); await resolver( { dispatch: mockDispatch, registry: mockRegistry, + select: mockSelectInstance, } ); expect( mockDispatch ).toHaveBeenCalledWith( @@ -118,6 +163,7 @@ describe( 'Store Resolvers', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'First test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -125,6 +171,7 @@ describe( 'Store Resolvers', () => { name: 'test/ability2', label: 'Test Ability 2', description: 'Second test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -132,6 +179,7 @@ describe( 'Store Resolvers', () => { name: 'test/ability3', label: 'Test Ability 3', description: 'Third test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -141,12 +189,17 @@ describe( 'Store Resolvers', () => { getEntityRecords: jest.fn().mockResolvedValue( allAbilities ), }; + const mockSelectInstance = { + getAbilities: jest.fn().mockReturnValue( [] ), // Store is empty + }; + mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); const resolver = getAbilities(); await resolver( { dispatch: mockDispatch, registry: mockRegistry, + select: mockSelectInstance, } ); // Should fetch all abilities in one request with per_page: -1 @@ -172,6 +225,7 @@ describe( 'Store Resolvers', () => { name: 'test/ability', label: 'Test Ability', description: 'Test ability description', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; @@ -211,6 +265,7 @@ describe( 'Store Resolvers', () => { name: 'test/ability', label: 'Test Ability', description: 'Already in store', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, callback: jest.fn(), @@ -262,6 +317,7 @@ describe( 'Store Resolvers', () => { name: 'my-plugin/feature-action', label: 'Namespaced Action', description: 'Namespaced ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; diff --git a/packages/client/src/store/__tests__/selectors.test.ts b/packages/client/src/store/__tests__/selectors.test.ts index 7e837c9..8577e17 100644 --- a/packages/client/src/store/__tests__/selectors.test.ts +++ b/packages/client/src/store/__tests__/selectors.test.ts @@ -17,6 +17,7 @@ describe( 'Store Selectors', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'First test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -24,6 +25,7 @@ describe( 'Store Selectors', () => { name: 'test/ability2', label: 'Test Ability 2', description: 'Second test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, callback: jest.fn(), @@ -59,6 +61,7 @@ describe( 'Store Selectors', () => { name: 'test/ability', label: 'Test Ability', description: 'Test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -79,6 +82,7 @@ describe( 'Store Selectors', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'Test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -92,6 +96,7 @@ describe( 'Store Selectors', () => { name: 'test/ability2', label: 'Test Ability 2', description: 'Another test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -106,6 +111,69 @@ describe( 'Store Selectors', () => { expect( result1 ).toHaveLength( 1 ); expect( result2 ).toHaveLength( 2 ); } ); + + it( 'should filter abilities by category when category is provided', () => { + const state: AbilitiesState = { + abilitiesByName: { + 'test/ability1': { + name: 'test/ability1', + label: 'Test Ability 1', + description: 'First test ability', + category: 'data-retrieval', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + 'test/ability2': { + name: 'test/ability2', + label: 'Test Ability 2', + description: 'Second test ability', + category: 'data-retrieval', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + 'test/ability3': { + name: 'test/ability3', + label: 'Test Ability 3', + description: 'Third test ability', + category: 'user-management', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + }, + }; + + const result = getAbilities( state, { category: 'data-retrieval' } ); + + expect( result ).toHaveLength( 2 ); + expect( result ).toContainEqual( + expect.objectContaining( { name: 'test/ability1' } ) + ); + expect( result ).toContainEqual( + expect.objectContaining( { name: 'test/ability2' } ) + ); + expect( result ).not.toContainEqual( + expect.objectContaining( { name: 'test/ability3' } ) + ); + } ); + + it( 'should return empty array when no abilities match category', () => { + const state: AbilitiesState = { + abilitiesByName: { + 'test/ability1': { + name: 'test/ability1', + label: 'Test Ability 1', + description: 'First test ability', + category: 'data-retrieval', + input_schema: { type: 'object' }, + output_schema: { type: 'object' }, + }, + }, + }; + + const result = getAbilities( state, { category: 'non-existent-category' } ); + + expect( result ).toEqual( [] ); + } ); } ); describe( 'getAbility', () => { @@ -115,6 +183,7 @@ describe( 'Store Selectors', () => { name: 'test/ability1', label: 'Test Ability 1', description: 'First test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, @@ -122,6 +191,7 @@ describe( 'Store Selectors', () => { name: 'test/ability2', label: 'Test Ability 2', description: 'Second test ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, callback: jest.fn(), @@ -169,6 +239,7 @@ describe( 'Store Selectors', () => { name: 'my-plugin/feature-action', label: 'Namespaced Action', description: 'Namespaced ability', + category: 'test-category', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }, diff --git a/packages/client/src/store/actions.ts b/packages/client/src/store/actions.ts index f815473..83e4439 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -62,6 +62,25 @@ export function registerAbility( ability: Ability ) { ); } + if ( ! ability.category ) { + throw new Error( + sprintf( 'Ability "%s" must have a category', ability.name ) + ); + } + + // 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. + if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( ability.category ) ) { + throw new Error( + sprintf( + 'Ability "%1$s" has an invalid category. Category must be lowercase alphanumeric with dashes only Got: "%2$s"', + 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/reducer.ts b/packages/client/src/store/reducer.ts index 8e05fc6..f0fb995 100644 --- a/packages/client/src/store/reducer.ts +++ b/packages/client/src/store/reducer.ts @@ -21,6 +21,7 @@ const ABILITY_KEYS = [ 'name', 'label', 'description', + 'category', 'input_schema', 'output_schema', 'meta', diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index 10461a6..8653bd4 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -6,16 +6,34 @@ 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'; /** * Resolver for getAbilities selector. * Fetches all abilities from the server. + * + * The resolver only fetches once (without query args filter) and stores all abilities. + * Query args filtering handled client-side by the selector for better performance + * and to avoid multiple API requests when filtering by different categories. */ export function getAbilities() { // @ts-expect-error - registry types are not yet available - return async ( { dispatch, registry } ) => { + return async ( { dispatch, registry, select } ) => { + const existingAbilities = select.getAbilities(); + + // Check if we have any server-side abilities (abilities without callbacks) + // Client abilities have callbacks and are registered immediately on page load + // We only want to skip fetching if we've already fetched server abilities + const hasServerAbilities = existingAbilities.some( + ( ability: Ability ) => ! ability.callback + ); + + if ( hasServerAbilities ) { + return; + } + const abilities = await registry .resolveSelect( coreStore ) .getEntityRecords( ENTITY_KIND, ENTITY_NAME, { diff --git a/packages/client/src/store/selectors.ts b/packages/client/src/store/selectors.ts index efc611c..e81b10f 100644 --- a/packages/client/src/store/selectors.ts +++ b/packages/client/src/store/selectors.ts @@ -6,19 +6,30 @@ import { createSelector } from '@wordpress/data'; /** * Internal dependencies */ -import type { Ability, AbilitiesState } from '../types'; +import type { Ability, AbilitiesQueryArgs, AbilitiesState } from '../types'; /** * Returns all registered abilities. + * Optionally filters by query arguments. * * @param state Store state. + * @param args Optional query arguments to filter. Defaults to empty object. * @return Array of abilities. */ export const getAbilities = createSelector( - ( state: AbilitiesState ): Ability[] => { - return Object.values( state.abilitiesByName ); + ( state: AbilitiesState, { category }: AbilitiesQueryArgs = {} ): Ability[] => { + const abilities = Object.values( state.abilitiesByName ); + if ( category ) { + return abilities.filter( + ( ability ) => ability.category === category + ); + } + return abilities; }, - ( state: AbilitiesState ) => [ state.abilitiesByName ] + ( state: AbilitiesState, category?: string ) => [ + state.abilitiesByName, + category, + ] ); /** diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 5dd0466..38d7979 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -42,6 +42,14 @@ export interface Ability { */ description: string; + /** + * The category this ability belongs to. + * Must be a valid category slug (lowercase alphanumeric with dashes). + * Example: 'data-retrieval', 'user-management' + * @see WP_Ability::get_category() + */ + category: string; + /** * JSON Schema for the ability's input parameters. * @see WP_Ability::get_input_schema() @@ -78,11 +86,21 @@ export interface Ability { readonly?: boolean; destructive?: boolean; idempotent?: boolean; - }, + }; [ key: string ]: any; }; } +/** + * The shape of the arguments for querying abilities. + */ +export interface AbilitiesQueryArgs { + /** + * Optional category slug to filter abilities. + */ + category?: string; +} + /** * The state shape for the abilities store. */