From 8ef617483209f34b6a338c844ce48829e17f15c0 Mon Sep 17 00:00:00 2001 From: Em Date: Mon, 13 Oct 2025 15:23:10 -0400 Subject: [PATCH 1/4] add: Client updates for categories --- packages/client/README.md | 68 +++++++++++++- packages/client/src/__tests__/api.test.ts | 49 +++++++++- packages/client/src/api.ts | 5 +- .../src/store/__tests__/actions.test.ts | 89 +++++++++++++++++++ .../src/store/__tests__/resolvers.test.ts | 60 ++++++++++++- .../src/store/__tests__/selectors.test.ts | 71 +++++++++++++++ packages/client/src/store/actions.ts | 19 ++++ packages/client/src/store/reducer.ts | 1 + packages/client/src/store/resolvers.ts | 24 ++++- packages/client/src/store/selectors.ts | 19 +++- packages/client/src/types.ts | 10 ++- 11 files changed, 400 insertions(+), 15 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 6da54bca..a6d273ca 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -75,13 +75,18 @@ function MyComponent() { ### Functions -#### `getAbilities(): Promise` +#### `getAbilities(category?: string): 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( 'data-retrieval' ); +console.log( `Found ${ dataAbilities.length } data retrieval abilities` ); ``` #### `getAbility(name: string): Promise` @@ -95,6 +100,32 @@ if ( ability ) { } ``` +#### `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,9 +150,40 @@ const result = await executeAbility( 'my-plugin/create-item', { When using with `@wordpress/data`: -- `getAbilities()` - Returns all abilities from the store +- `getAbilities(category?)` - Returns all abilities from the store, optionally filtered by category - `getAbility(name)` - 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( 'data-retrieval' ), + [] + ); + + return ( +
+

All Abilities ({ allAbilities.length })

+

Data Retrieval Abilities

+
    + { dataAbilities.map( ( ability ) => ( +
  • { ability.label }
  • + ) ) } +
+
+ ); +} +``` + ## Development and Testing For development and contributing guidelines, see [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index 1455328f..bd18d03d 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( 'data-retrieval' ); + + expect( resolveSelect ).toHaveBeenCalledWith( store ); + expect( mockGetAbilities ).toHaveBeenCalledWith( '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 d56471d6..fc79765d 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -16,10 +16,11 @@ import { validateValueFromSchema } from './validation'; /** * Get all available abilities. * + * @param category Optional category slug to filter abilities. * @return Promise resolving to array of abilities. */ -export async function getAbilities(): Promise< Ability[] > { - return await resolveSelect( store ).getAbilities(); +export async function getAbilities( category?: string ): Promise< Ability[] > { + return await resolveSelect( store ).getAbilities( category ); } /** diff --git a/packages/client/src/store/__tests__/actions.test.ts b/packages/client/src/store/__tests__/actions.test.ts index 3ee5cac3..c2fe76e2 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 968804ec..f5c369eb 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( '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 7e837c9b..260e7517 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, '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, '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 f8154739..83e44395 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 8e05fc63..f0fb995e 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 10461a6a..01a0a7a2 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -6,16 +6,36 @@ 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 category filter) and stores all abilities. + * Category filtering handled client-side by the selector for better performance + * and to avoid multiple API requests when filtering by different categories. + * + * @param _category Optional category slug */ -export function getAbilities() { +export function getAbilities( _category?: string ) { // @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 efc611c4..d6909662 100644 --- a/packages/client/src/store/selectors.ts +++ b/packages/client/src/store/selectors.ts @@ -10,15 +10,26 @@ import type { Ability, AbilitiesState } from '../types'; /** * Returns all registered abilities. + * Optionally filters by category. * - * @param state Store state. + * @param state Store state. + * @param category Optional category slug to filter by. * @return Array of abilities. */ export const getAbilities = createSelector( - ( state: AbilitiesState ): Ability[] => { - return Object.values( state.abilitiesByName ); + ( state: AbilitiesState, category?: string ): 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 5dd0466f..da35c300 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,7 +86,7 @@ export interface Ability { readonly?: boolean; destructive?: boolean; idempotent?: boolean; - }, + }; [ key: string ]: any; }; } From 01c4b20f6d13c601bffe1055e604c86d857ebd69 Mon Sep 17 00:00:00 2001 From: Em Date: Mon, 13 Oct 2025 15:52:37 -0400 Subject: [PATCH 2/4] fix: remove unused param from resolver --- packages/client/src/store/resolvers.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index 01a0a7a2..79ea52fc 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -17,10 +17,8 @@ import { receiveAbilities } from './actions'; * The resolver only fetches once (without category filter) and stores all abilities. * Category filtering handled client-side by the selector for better performance * and to avoid multiple API requests when filtering by different categories. - * - * @param _category Optional category slug */ -export function getAbilities( _category?: string ) { +export function getAbilities() { // @ts-expect-error - registry types are not yet available return async ( { dispatch, registry, select } ) => { const existingAbilities = select.getAbilities(); From 80100bf2d7a04ff77dcd07f7a5ab3e3bdc7e7dfe Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 14 Oct 2025 14:47:38 +0200 Subject: [PATCH 3/4] Refactor filtering with `getAbilities` to account for query args --- packages/client/README.md | 23 ++++++++++--------- packages/client/src/__tests__/api.test.ts | 4 ++-- packages/client/src/api.ts | 10 ++++---- .../src/store/__tests__/resolvers.test.ts | 2 +- .../src/store/__tests__/selectors.test.ts | 4 ++-- packages/client/src/store/resolvers.ts | 4 ++-- packages/client/src/store/selectors.ts | 10 ++++---- packages/client/src/types.ts | 10 ++++++++ 8 files changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index a6d273ca..4588d2e8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -75,7 +75,7 @@ function MyComponent() { ### Functions -#### `getAbilities(category?: string): Promise` +#### `getAbilities( args: AbilitiesQueryArgs = {} ): Promise` Returns all registered abilities. Optionally filter by category slug. Automatically handles pagination to fetch all abilities across multiple pages if needed. @@ -85,11 +85,11 @@ const abilities = await getAbilities(); console.log( `Found ${ abilities.length } abilities` ); // Get abilities in a specific category -const dataAbilities = await getAbilities( 'data-retrieval' ); +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. @@ -100,7 +100,7 @@ if ( ability ) { } ``` -#### `registerAbility(ability: Ability): void` +#### `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. @@ -115,18 +115,18 @@ registerAbility( { input_schema: { type: 'object', properties: { - url: { type: 'string' } + url: { type: 'string' }, }, - required: [ 'url' ] + required: [ 'url' ], }, callback: async ( { url } ) => { window.location.href = url; return { success: true }; - } + }, } ); ``` -#### `executeAbility(name: string, input?: Record): Promise` +#### `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: @@ -150,8 +150,8 @@ const result = await executeAbility( 'my-plugin/create-item', { When using with `@wordpress/data`: -- `getAbilities(category?)` - Returns all abilities from the store, optionally filtered by category -- `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'; @@ -166,7 +166,8 @@ function MyComponent() { // Get abilities in a specific category const dataAbilities = useSelect( - ( select ) => select( abilitiesStore ).getAbilities( 'data-retrieval' ), + ( select ) => + select( abilitiesStore ).getAbilities( { category: 'data-retrieval' } ), [] ); diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index bd18d03d..9a0c874b 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -101,10 +101,10 @@ describe( 'API functions', () => { getAbilities: mockGetAbilities, } ); - const result = await getAbilities( 'data-retrieval' ); + const result = await getAbilities( { category: 'data-retrieval' } ); expect( resolveSelect ).toHaveBeenCalledWith( store ); - expect( mockGetAbilities ).toHaveBeenCalledWith( 'data-retrieval' ); + expect( mockGetAbilities ).toHaveBeenCalledWith( { category: 'data-retrieval' } ); expect( result ).toEqual( mockAbilities ); } ); } ); diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index fc79765d..2032fdf3 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -10,17 +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 category Optional category slug to filter abilities. + * @param args Optional query arguments to filter. Defaults to empty object. * @return Promise resolving to array of abilities. */ -export async function getAbilities( category?: string ): Promise< Ability[] > { - return await resolveSelect( store ).getAbilities( category ); +export async function getAbilities( args: AbilitiesQueryArgs = {} ): Promise< Ability[] > { + return await resolveSelect( store ).getAbilities( args ); } /** diff --git a/packages/client/src/store/__tests__/resolvers.test.ts b/packages/client/src/store/__tests__/resolvers.test.ts index f5c369eb..536fbfa7 100644 --- a/packages/client/src/store/__tests__/resolvers.test.ts +++ b/packages/client/src/store/__tests__/resolvers.test.ts @@ -99,7 +99,7 @@ describe( 'Store Resolvers', () => { mockRegistry.resolveSelect.mockReturnValue( mockResolveSelect ); - const resolver = getAbilities( 'data-retrieval' ); + const resolver = getAbilities( { category: 'data-retrieval' } ); await resolver( { dispatch: mockDispatch, registry: mockRegistry, diff --git a/packages/client/src/store/__tests__/selectors.test.ts b/packages/client/src/store/__tests__/selectors.test.ts index 260e7517..8577e17a 100644 --- a/packages/client/src/store/__tests__/selectors.test.ts +++ b/packages/client/src/store/__tests__/selectors.test.ts @@ -142,7 +142,7 @@ describe( 'Store Selectors', () => { }, }; - const result = getAbilities( state, 'data-retrieval' ); + const result = getAbilities( state, { category: 'data-retrieval' } ); expect( result ).toHaveLength( 2 ); expect( result ).toContainEqual( @@ -170,7 +170,7 @@ describe( 'Store Selectors', () => { }, }; - const result = getAbilities( state, 'non-existent-category' ); + const result = getAbilities( state, { category: 'non-existent-category' } ); expect( result ).toEqual( [] ); } ); diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index 79ea52fc..8653bd48 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -14,8 +14,8 @@ import { receiveAbilities } from './actions'; * Resolver for getAbilities selector. * Fetches all abilities from the server. * - * The resolver only fetches once (without category filter) and stores all abilities. - * Category filtering handled client-side by the selector for better performance + * 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() { diff --git a/packages/client/src/store/selectors.ts b/packages/client/src/store/selectors.ts index d6909662..e81b10fa 100644 --- a/packages/client/src/store/selectors.ts +++ b/packages/client/src/store/selectors.ts @@ -6,18 +6,18 @@ 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 category. + * Optionally filters by query arguments. * - * @param state Store state. - * @param category Optional category slug to filter by. + * @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, category?: string ): Ability[] => { + ( state: AbilitiesState, { category }: AbilitiesQueryArgs = {} ): Ability[] => { const abilities = Object.values( state.abilitiesByName ); if ( category ) { return abilities.filter( diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index da35c300..38d79799 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -91,6 +91,16 @@ export interface Ability { }; } +/** + * 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. */ From cf2e14e2a3f5b0149919915bd3013e41cae46d69 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 14 Oct 2025 14:56:42 +0200 Subject: [PATCH 4/4] Update docs --- docs/7.javascript-client.md | 156 ++++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 71 deletions(-) diff --git a/docs/7.javascript-client.md b/docs/7.javascript-client.md index 6b3b3a42..7de40a96 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 ); + } } ```