diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index 5a0296a9209cd..745e369e7c872 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -46,22 +46,24 @@ protected function __construct() {} * * @since 7.0.0 * @since 7.1.0 The icon name must be namespaced in the form "collection/icon-name". - * @since 7.2.0 Added the `public` property. + * @since 7.2.0 Added the `public` and `keywords` properties. * * @param string $icon_name Namespaced icon name in the form "collection/icon-name" * (e.g. "core/arrow-left"). * @param array $icon_properties { * List of properties for the icon. * - * @type string $label Required. A human-readable label for the icon. - * @type string $content Optional. SVG markup for the icon. - * If not provided, the content will be retrieved from the `file_path` if set. - * If both `content` and `file_path` are not set, the icon will not be registered. - * @type string $file_path Optional. The full path to the file containing the icon content. - * @type bool $public Optional. Whether the icon is exposed through the REST API, and - * therefore selectable in the editor's icon picker. Non-public icons - * stay available to server-side code via {@see wp_get_icon()}. - * Default true. + * @type string $label Required. A human-readable label for the icon. + * @type string $content Optional. SVG markup for the icon. + * If not provided, the content will be retrieved from the `file_path` if set. + * If both `content` and `file_path` are not set, the icon will not be registered. + * @type string $file_path Optional. The full path to the file containing the icon content. + * @type bool $public Optional. Whether the icon is exposed through the REST API, and + * therefore selectable in the editor's icon picker. Non-public icons + * stay available to server-side code via {@see wp_get_icon()}. + * Default true. + * @type string[] $keywords Optional. Additional search terms for the icon, matched by + * `get_registered_icons()` alongside the name and label. * } * @return bool True if the icon was registered with success and false otherwise. */ @@ -106,7 +108,7 @@ public function register( $icon_name, $icon_properties ) { return false; } - $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'public' ), 1 ); + $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'public', 'keywords' ), 1 ); foreach ( array_keys( $icon_properties ) as $key ) { if ( ! array_key_exists( $key, $allowed_keys ) ) { _doing_it_wrong( @@ -153,6 +155,28 @@ public function register( $icon_name, $icon_properties ) { return false; } + if ( array_key_exists( 'keywords', $icon_properties ) ) { + if ( ! is_array( $icon_properties['keywords'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon keywords must be an array of strings.' ), + '7.2.0' + ); + return false; + } + + foreach ( $icon_properties['keywords'] as $keyword ) { + if ( ! is_string( $keyword ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon keywords must be an array of strings.' ), + '7.2.0' + ); + return false; + } + } + } + if ( ( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['file_path'] ) ) || ( isset( $icon_properties['content'] ) && isset( $icon_properties['file_path'] ) ) @@ -401,23 +425,53 @@ public function get_registered_icon( $icon_name ) { return $icon; } + /** + * Determines whether an icon matches a search term. + * + * The term is matched case-insensitively against the icon's name, its label, + * and any of its keywords. + * + * @since 7.2.0 + * + * @param array $icon Registered icon properties. + * @param string $search Search term. + * @return bool True if the icon matches the search term, false otherwise. + */ + protected function icon_matches_search( $icon, $search ) { + if ( false !== stripos( $icon['name'], $search ) ) { + return true; + } + + if ( false !== stripos( $icon['label'], $search ) ) { + return true; + } + + foreach ( $icon['keywords'] ?? array() as $keyword ) { + if ( false !== stripos( $keyword, $search ) ) { + return true; + } + } + + return false; + } + /** * Retrieves all registered icons. * * @since 7.0.0 * @since 7.1.0 Search also matches icon labels. + * @since 7.2.0 Search also matches icon keywords. * - * @param string $search Optional. Search term by which to filter the icons. + * @param string $search Optional. Search term matched against each icon's name, + * label, and keywords. Default empty string, which returns + * every registered icon. * @return array[] Array of arrays containing the registered icon properties. */ public function get_registered_icons( $search = '' ) { $icons = array(); foreach ( $this->registered_icons as $icon ) { - if ( ! empty( $search ) - && false === stripos( $icon['name'], $search ) - && false === stripos( $icon['label'] ?? '', $search ) - ) { + if ( ! empty( $search ) && ! $this->icon_matches_search( $icon, $search ) ) { continue; } diff --git a/src/wp-includes/icons.php b/src/wp-includes/icons.php index 4c906ad939bc7..e6916aaed1ed0 100644 --- a/src/wp-includes/icons.php +++ b/src/wp-includes/icons.php @@ -41,7 +41,7 @@ function wp_unregister_icon_collection( $slug ) { * Registers a new icon. * * @since 7.1.0 - * @since 7.2.0 Added the `public` property. + * @since 7.2.0 Added the `public` and `keywords` properties. * * @param string $icon_name Namespaced icon name in the form "collection/icon-name" * (e.g. "my-plugin/arrow-left"). The "core" collection is @@ -51,15 +51,17 @@ function wp_unregister_icon_collection( $slug ) { * @param array $args { * List of properties for the icon. * - * @type string $label Required. A human-readable label for the icon. - * @type string $content Optional. SVG markup for the icon. - * If not provided, the content will be retrieved from the `file_path` if set. - * If both `content` and `file_path` are not set, the icon will not be registered. - * @type string $file_path Optional. The full path to the file containing the icon content. - * @type bool $public Optional. Whether the icon is exposed through the REST API, and - * therefore selectable in the editor's icon picker. Non-public icons - * stay available to server-side code via {@see wp_get_icon()}. - * Default true. + * @type string $label Required. A human-readable label for the icon. + * @type string $content Optional. SVG markup for the icon. + * If not provided, the content will be retrieved from the `file_path` if set. + * If both `content` and `file_path` are not set, the icon will not be registered. + * @type string $file_path Optional. The full path to the file containing the icon content. + * @type bool $public Optional. Whether the icon is exposed through the REST API, and + * therefore selectable in the editor's icon picker. Non-public icons + * stay available to server-side code via {@see wp_get_icon()}. + * Default true. + * @type string[] $keywords Optional. Additional search terms for the icon, matched by + * `get_registered_icons()` alongside the name and label. * } * @return bool True if the icon was registered successfully, else false. */ @@ -146,6 +148,10 @@ function _wp_register_default_icons() { $icon_args['public'] = $icon_data['public']; } + if ( isset( $icon_data['keywords'] ) ) { + $icon_args['keywords'] = $icon_data['keywords']; + } + wp_register_icon( 'core/' . $icon_name, $icon_args ); } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php index 4578df9d28c1e..610cc582304dd 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php @@ -229,8 +229,12 @@ public function get_icon( $name ) { /** * Prepare a raw icon before it gets output in a REST API response. * + * Adds `collection` and `keywords` fields to the base response while keeping + * the namespaced icon name (e.g. `core/arrow-left`) as the `name` field. + * * @since 7.0.0 * @since 7.1.0 Added the `collection` field. + * @since 7.2.0 Added the `keywords` field. * * @param array $item Raw icon as registered, before any changes. * @param WP_REST_Request $request Request object. @@ -251,6 +255,14 @@ public function prepare_item_for_response( $item, $request ) { } } + /* + * Keywords are optional at registration time, but the field is always + * present in the response so consumers do not have to handle its absence. + */ + if ( rest_is_field_included( 'keywords', $fields ) ) { + $data['keywords'] = isset( $item['keywords'] ) ? array_values( $item['keywords'] ) : array(); + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -262,6 +274,7 @@ public function prepare_item_for_response( $item, $request ) { * * @since 7.0.0 * @since 7.1.0 Added the `collection` property. + * @since 7.2.0 Added the `keywords` property. * * @return array Item schema data. */ @@ -299,6 +312,15 @@ public function get_item_schema() { 'readonly' => true, 'context' => array( 'view', 'edit', 'embed' ), ), + 'keywords' => array( + 'description' => __( 'Additional search terms for the icon.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), ), ); diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index 4f87bb98ede8b..621ff00b0df20 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -542,4 +542,156 @@ public function test_register_rejects_non_boolean_public_property() { $this->assertFalse( $result ); $this->assertFalse( $this->registry->is_registered( 'test-collection/invalid-visibility' ) ); } + + /** + * Should register an icon that provides a valid `keywords` array. + * + * @ticket 66158 + */ + public function test_register_icon_with_keywords() { + $name = 'test-collection/with-keywords'; + + $result = $this->registry->register( + $name, + array( + 'label' => 'Icon', + 'content' => '', + 'keywords' => array( 'alpha', 'beta' ), + ) + ); + + $this->assertTrue( $result ); + + $icon = $this->registry->get_registered_icon( $name ); + $this->assertSame( array( 'alpha', 'beta' ), $icon['keywords'] ); + } + + /** + * Should register an icon that omits `keywords`, since the property is optional. + * + * @ticket 66158 + */ + public function test_register_icon_without_keywords() { + $name = 'test-collection/without-keywords'; + + $result = $this->registry->register( + $name, + array( + 'label' => 'Icon', + 'content' => '', + ) + ); + + $this->assertTrue( $result ); + + $icon = $this->registry->get_registered_icon( $name ); + $this->assertArrayNotHasKey( 'keywords', $icon ); + } + + /** + * Provides values that are not an array of strings. + * + * @return array + */ + public function data_invalid_keywords() { + return array( + 'null' => array( null ), + 'a string' => array( 'alpha' ), + 'an integer' => array( 5 ), + 'an array of integers' => array( array( 1, 2 ) ), + 'a mixed array' => array( array( 'alpha', 5 ) ), + 'a nested array' => array( array( array( 'alpha' ) ) ), + 'an array of null' => array( array( null ) ), + ); + } + + /** + * Should fail to register an icon whose `keywords` is not an array of strings. + * + * @ticket 66158 + * + * @dataProvider data_invalid_keywords + * @expectedIncorrectUsage WP_Icons_Registry::register + * + * @param mixed $keywords Invalid keywords candidate. + */ + public function test_register_icon_with_invalid_keywords( $keywords ) { + $name = 'test-collection/invalid-keywords'; + + $result = $this->registry->register( + $name, + array( + 'label' => 'Icon', + 'content' => '', + 'keywords' => $keywords, + ) + ); + + $this->assertFalse( $result ); + $this->assertFalse( $this->registry->is_registered( $name ) ); + } + + /** + * Should match an icon by keyword when neither its name nor its label match. + * + * @ticket 66158 + */ + public function test_get_registered_icons_matches_keywords() { + $this->registry->register( + 'test-collection/dove', + array( + 'label' => 'Dove', + 'content' => '', + 'keywords' => array( 'peace' ), + ) + ); + $this->registry->register( + 'test-collection/anvil', + array( + 'label' => 'Anvil', + 'content' => '', + ) + ); + + /* + * The search term is deliberately absent from both the name and the label, + * so a match can only come from the keywords. + */ + $icon = $this->registry->get_registered_icon( 'test-collection/dove' ); + $this->assertStringNotContainsStringIgnoringCase( 'peace', $icon['name'] ); + $this->assertStringNotContainsStringIgnoringCase( 'peace', $icon['label'] ); + + $names = array_column( $this->registry->get_registered_icons( 'peace' ), 'name' ); + + $this->assertContains( + 'test-collection/dove', + $names, + 'Search results should include an icon matched only by its keyword' + ); + $this->assertNotContains( + 'test-collection/anvil', + $names, + 'Search results should exclude an icon that matches on no property' + ); + } + + /** + * Should match keywords case-insensitively, as names and labels are. + * + * @ticket 66158 + */ + public function test_get_registered_icons_matches_keywords_case_insensitively() { + $this->registry->register( + 'test-collection/dove', + array( + 'label' => 'Dove', + 'content' => '', + 'keywords' => array( 'peace' ), + ) + ); + + $names = array_column( $this->registry->get_registered_icons( 'PEACE' ), 'name' ); + + $this->assertContains( 'test-collection/dove', $names ); + } } diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index 612c2f4ab846d..cdc9793242461 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -249,6 +249,7 @@ public function test_prepare_item() { /** * @ticket 40538 * @ticket 64651 + * @ticket 66158 * * @covers ::get_item_schema */ @@ -258,11 +259,12 @@ public function test_get_item_schema() { $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 4, $properties ); + $this->assertCount( 5, $properties ); $this->assertArrayHasKey( 'name', $properties ); $this->assertArrayHasKey( 'label', $properties ); $this->assertArrayHasKey( 'content', $properties ); $this->assertArrayHasKey( 'collection', $properties ); + $this->assertArrayHasKey( 'keywords', $properties ); } /** @@ -432,6 +434,118 @@ public function test_get_items_search_includes_label() { $this->assertSame( array( 'core/at-symbol' ), array_column( $data, 'name' ) ); } + /** + * Test that GET /wp/v2/icons/?search=%s searches icon keywords too. + * + * @ticket 66158 + */ + public function test_get_items_search_includes_keywords() { + wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); + wp_register_icon( + 'rest-test-collection/dove', + array( + 'label' => 'Dove', + 'content' => '', + 'keywords' => array( 'peace' ), + ) + ); + wp_register_icon( + 'rest-test-collection/anvil', + array( + 'label' => 'Anvil', + 'content' => '', + ) + ); + + wp_set_current_user( self::$editor_id ); + + try { + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + + /* + * The search term appears in no icon's name or label, so a match can + * only come from the keywords. + */ + $request->set_param( 'search', 'peace' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + array( 'rest-test-collection/dove' ), + array_column( $data, 'name' ), + 'Search results should contain only the icon matched by its keyword' + ); + } finally { + wp_unregister_icon_collection( 'rest-test-collection' ); + } + } + + /** + * Test that the response exposes an icon's keywords, so that clients which + * filter icons locally can match against them. + * + * @ticket 66158 + */ + public function test_get_items_response_includes_keywords() { + wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); + wp_register_icon( + 'rest-test-collection/dove', + array( + 'label' => 'Dove', + 'content' => '', + 'keywords' => array( 'peace', 'bird' ), + ) + ); + + wp_set_current_user( self::$editor_id ); + + try { + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $request->set_param( 'search', 'rest-test-collection/dove' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data ); + $this->assertArrayHasKey( 'keywords', $data[0] ); + $this->assertSame( array( 'peace', 'bird' ), $data[0]['keywords'] ); + } finally { + wp_unregister_icon_collection( 'rest-test-collection' ); + } + } + + /** + * Test that icons registered without keywords still expose an empty array, + * so consumers do not have to handle a missing field. + * + * @ticket 66158 + */ + public function test_get_items_response_keywords_defaults_to_empty_array() { + wp_set_current_user( self::$editor_id ); + + wp_register_icon( + 'core/no-keywords', + array( + 'label' => 'No Keywords', + 'content' => '', + ) + ); + + try { + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $request->set_param( 'search', 'core/no-keywords' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data ); + $this->assertSame( array(), $data[0]['keywords'] ); + } finally { + wp_unregister_icon( 'core/no-keywords' ); + } + } + /** * Test that search is case-insensitive. *