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/README.md b/packages/client/README.md index 4588d2e8..9b276eca 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', @@ -126,6 +151,61 @@ 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: @@ -152,6 +232,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 +246,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 +259,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..5f761d0d 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,108 @@ 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..d509ce81 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -10,7 +10,14 @@ import { sprintf } from '@wordpress/i18n'; * Internal dependencies */ import { store } from './store'; -import type { Ability, AbilitiesQueryArgs, AbilityInput, AbilityOutput } from './types'; +import type { + Ability, + AbilityCategory, + AbilityCategoryArgs, + AbilitiesQueryArgs, + AbilityInput, + AbilityOutput, +} from './types'; import { validateValueFromSchema } from './validation'; /** @@ -19,7 +26,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 +42,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 +70,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 +99,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 ); } /** @@ -79,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 0fec39c4..75f9469a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -31,9 +31,13 @@ declare global { export { getAbilities, getAbility, + getAbilityCategories, + getAbilityCategory, executeAbility, registerAbility, unregisterAbility, + registerAbilityCategory, + unregisterAbilityCategory, } from './api'; /** @@ -61,7 +65,10 @@ export { store } from './store'; */ export type { Ability, + AbilityCategory, + AbilityCategoryArgs, 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..cc759a2a 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 */ @@ -9,13 +14,28 @@ 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', () => ( { + resolveSelect: jest.fn(), +} ) ); describe( 'Store Actions', () => { describe( 'receiveAbilities', () => { @@ -64,13 +84,51 @@ 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( defaultCategories ), + 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(); + + // Mock resolveSelect to return categories + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest + .fn() + .mockResolvedValue( defaultCategories ), + } ); } ); - 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 +150,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 +158,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 +169,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 +177,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 +188,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 +204,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 +215,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 +236,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 +253,15 @@ 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 +272,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 +289,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 +300,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 +318,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 +327,124 @@ describe( 'Store Actions', () => { callback: jest.fn(), }; + const categoriesForTest = [ + { + 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', + }, + ]; + + // 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(); 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 () => { + 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', + 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 () => { + const categoriesForTest = [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category for testing', + }, + { + slug: 'data-retrieval', + 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', + label: 'Test Ability', + description: 'Test description', + category: 'data-retrieval', + callback: jest.fn(), + }; + + const action = registerAbility( ability ); + await action( { select: mockSelect, dispatch: mockDispatch } ); + + // resolveSelect should have been called to load categories + expect( resolveSelect ).toHaveBeenCalledWith( + 'abilities-api/abilities' + ); + 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 +455,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,11 +483,47 @@ 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(); } ); + + it( 'should load categories using resolveSelect before validation', async () => { + const categories = [ + { + slug: 'test-category', + label: 'Test Category', + description: 'Test category', + }, + ]; + + // Mock resolveSelect to return categories directly + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbilityCategories: jest.fn().mockResolvedValue( categories ), + } ); + + 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 resolveSelect to load categories + expect( resolveSelect ).toHaveBeenCalledWith( + 'abilities-api/abilities' + ); + // Should have successfully registered + expect( mockDispatch ).toHaveBeenCalledWith( { + type: REGISTER_ABILITY, + ability, + } ); + } ); } ); describe( 'unregisterAbility', () => { @@ -345,4 +547,408 @@ 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, + meta: { + _clientRegistered: true, + }, + }, + } ); + } ); + + 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, + _clientRegistered: true, + }, + }, + } ); + } ); + + 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, + meta: { + _clientRegistered: true, + }, + }, + } ); + } + } ); + + 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, + meta: { + _clientRegistered: true, + }, + }, + } ); + + // Now mock that the category exists for ability registration + 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(); + + // 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 f3d65b04..3b7de3f3 100644 --- a/packages/client/src/store/__tests__/reducer.test.ts +++ b/packages/client/src/store/__tests__/reducer.test.ts @@ -10,6 +10,9 @@ import { RECEIVE_ABILITIES, REGISTER_ABILITY, UNREGISTER_ABILITY, + RECEIVE_CATEGORIES, + REGISTER_ABILITY_CATEGORY, + UNREGISTER_ABILITY_CATEGORY, } from '../constants'; describe( 'Store Reducer', () => { @@ -390,4 +393,467 @@ 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' ); + } ); + } ); + + 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/__tests__/resolvers.test.ts b/packages/client/src/store/__tests__/resolvers.test.ts index 536fbfa7..cbcbd808 100644 --- a/packages/client/src/store/__tests__/resolvers.test.ts +++ b/packages/client/src/store/__tests__/resolvers.test.ts @@ -10,10 +10,15 @@ 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 +351,284 @@ 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 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 ), + }; + + 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..bb644e26 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,136 @@ 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..8b156ba5 100644 --- a/packages/client/src/store/actions.ts +++ b/packages/client/src/store/actions.ts @@ -2,15 +2,20 @@ * WordPress dependencies */ import { sprintf } from '@wordpress/i18n'; +import { resolveSelect } from '@wordpress/data'; /** * Internal dependencies */ -import type { Ability } 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'; /** @@ -26,11 +31,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 +57,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 +87,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 +98,22 @@ export function registerAbility( ability: Ability ) { ); } + // Ensure categories are loaded before validating + const categories = + await resolveSelect( STORE_NAME ).getAbilityCategories(); + const existingCategory = categories.find( + ( cat: AbilityCategory ) => cat.slug === 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( @@ -119,3 +152,99 @@ 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 + await resolveSelect( STORE_NAME ).getAbilityCategories(); + 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, + 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( { + 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 cca77b19..c32d331d 100644 --- a/packages/client/src/store/constants.ts +++ b/packages/client/src/store/constants.ts @@ -4,8 +4,12 @@ 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'; +export const REGISTER_ABILITY_CATEGORY = 'REGISTER_ABILITY_CATEGORY'; +export const UNREGISTER_ABILITY_CATEGORY = 'UNREGISTER_ABILITY_CATEGORY'; 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..89227fe3 100644 --- a/packages/client/src/store/reducer.ts +++ b/packages/client/src/store/reducer.ts @@ -6,11 +6,14 @@ 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, + REGISTER_ABILITY_CATEGORY, + UNREGISTER_ABILITY_CATEGORY, } from './constants'; /** @@ -29,6 +32,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,11 +58,34 @@ 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[]; + category?: AbilityCategory; name?: string; + slug?: string; } const DEFAULT_STATE: Record< string, Ability > = {}; @@ -102,6 +134,53 @@ 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; + } + 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; + } +} + export default combineReducers( { abilitiesByName, + categoriesBySlug, } ); diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index 8653bd48..a932b803 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,67 @@ 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(); + + // 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; + } + + 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 } ) => { + // 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; + } + + 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..dc191d9e 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,30 @@ export function getAbility( ): Ability | null { return state.abilitiesByName[ name ] || null; } + +/** + * Returns all registered ability categories. + * + * @param state Store state. + * @return Array of categories. + */ +export const getAbilityCategories = createSelector( + ( state: AbilitiesState ): AbilityCategory[] => { + return Object.values( state.categoriesBySlug ); + }, + ( state: AbilitiesState ) => [ 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..934204cb 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -101,6 +101,62 @@ 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 >; +} + +/** + * 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. */ @@ -109,6 +165,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 >; } /**