diff --git a/backport-changelog/7.2/13672.md b/backport-changelog/7.2/13672.md new file mode 100644 index 00000000000000..18d0cb867cae65 --- /dev/null +++ b/backport-changelog/7.2/13672.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/13672 + +* https://github.com/WordPress/gutenberg/pull/82367 diff --git a/lib/class-wp-icons-registry-gutenberg.php b/lib/class-wp-icons-registry-gutenberg.php index 9bd3784a1ce97a..c18d72c8719d65 100644 --- a/lib/class-wp-icons-registry-gutenberg.php +++ b/lib/class-wp-icons-registry-gutenberg.php @@ -107,11 +107,13 @@ protected function __construct() {} * @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 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 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. */ @@ -154,7 +156,7 @@ public function register( $icon_name, $icon_properties ) { return false; } - $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path' ), 1 ); + $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'keywords' ), 1 ); foreach ( array_keys( $icon_properties ) as $key ) { if ( ! array_key_exists( $key, $allowed_keys ) ) { _doing_it_wrong( @@ -192,6 +194,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.', 'gutenberg' ), + '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.', 'gutenberg' ), + '7.2.0' + ); + return false; + } + } + } + if ( ( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['file_path'] ) ) || ( isset( $icon_properties['content'] ) && isset( $icon_properties['file_path'] ) ) @@ -338,16 +362,45 @@ protected function get_content( $icon_name ) { } /** - * Modified to also search in icon labels + * 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. + * + * @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; + } + + /** + * Modified to also search in icon labels and keywords. + * + * @param string $search Optional. Search term matched against each icon's name, + * label, and keywords. Default empty string, which returns + * every registered icon. */ 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; } @@ -384,6 +437,9 @@ public static function get_instance() { } else { continue; } + if ( ! empty( $icon['keywords'] ) ) { + $icon_properties['keywords'] = $icon['keywords']; + } $gutenberg_registry->register( $icon['name'], $icon_properties ); } } diff --git a/lib/class-wp-rest-icons-controller-gutenberg.php b/lib/class-wp-rest-icons-controller-gutenberg.php index faa9ae5791b127..8a09713993a41e 100644 --- a/lib/class-wp-rest-icons-controller-gutenberg.php +++ b/lib/class-wp-rest-icons-controller-gutenberg.php @@ -166,8 +166,8 @@ public function get_icon( $name ) { /** * Prepare a raw icon before it gets output in a REST API response. * - * Adds a `collection` field to the base response while keeping the - * namespaced icon name (e.g. `core/arrow-left`) as the `name` field. + * Adds `collection` and `keywords` fields to the base response while keeping + * the namespaced icon name (e.g. `core/arrow-left`) as the `name` field. * * @param array $item Raw icon as registered. * @param WP_REST_Request $request Request object. @@ -183,6 +183,12 @@ public function prepare_item_for_response( $item, $request ) { $response->set_data( $data ); } + if ( rest_is_field_included( 'keywords', $fields ) ) { + $data = $response->get_data(); + $data['keywords'] = isset( $item['keywords'] ) ? array_values( $item['keywords'] ) : array(); + $response->set_data( $data ); + } + return $response; } @@ -205,6 +211,14 @@ public function get_item_schema() { 'context' => array( 'view', 'edit', 'embed' ), ); + $schema['properties']['keywords'] = array( + 'description' => __( 'Additional search terms for the icon.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ); + $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); diff --git a/lib/compat/wordpress-7.1/icons.php b/lib/compat/wordpress-7.1/icons.php index 4e4f5ad6c2a91e..8e97cb17560293 100644 --- a/lib/compat/wordpress-7.1/icons.php +++ b/lib/compat/wordpress-7.1/icons.php @@ -51,11 +51,13 @@ 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 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 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. */ diff --git a/lib/icons.php b/lib/icons.php index 84b64c4ec4e39b..6410d7ab833f86 100644 --- a/lib/icons.php +++ b/lib/icons.php @@ -86,6 +86,10 @@ function gutenberg_register_default_icons() { 'file_path' => $icons_directory . $icon_data['filePath'], ); + if ( isset( $icon_data['keywords'] ) ) { + $icon_args['keywords'] = $icon_data['keywords']; + } + foreach ( $icon_data['collections'] as $collection_slug ) { wp_register_icon( $collection_slug . '/' . $icon_name, $icon_args ); } diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index 74c584040dfdae..83b3a941cce419 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -8,6 +8,7 @@ - Columns: Remove the column count slider from the block settings ([#83262](https://github.com/WordPress/gutenberg/pull/83262)). - Media & Text: Lower the specificity of the content area's default padding so themes can override it, including through the block's custom CSS in `theme.json` ([#83563](https://github.com/WordPress/gutenberg/pull/83563)). - Gallery: Add an "Order by" sorting control to the static gallery mode, and consolidate sorting control for static and dynamic modes in the Settings panel ([#83438](https://github.com/WordPress/gutenberg/pull/83438)). +- Icon: Match an icon's keywords as well as its name and label when searching the icon library ([#82367](https://github.com/WordPress/gutenberg/pull/82367)). ### Bug Fixes diff --git a/packages/block-library/src/icon/components/custom-inserter/index.jsx b/packages/block-library/src/icon/components/custom-inserter/index.jsx index 86d74f834d4f99..142e09b98b68c8 100644 --- a/packages/block-library/src/icon/components/custom-inserter/index.jsx +++ b/packages/block-library/src/icon/components/custom-inserter/index.jsx @@ -65,7 +65,11 @@ export default function CustomInserterModal( { onClose, value, onChange } ) { const iconLabel = normalizeSearchInput( icon.label ); return ( - iconName.includes( input ) || iconLabel.includes( input ) + iconName.includes( input ) || + iconLabel.includes( input ) || + ( icon.keywords ?? [] ).some( ( keyword ) => + normalizeSearchInput( keyword ).includes( input ) + ) ); } ); } diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index b65c4515331163..fe9ae77484b3c5 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- `Icon`: add the `keywords` field exposed by the icons REST endpoint ([#82367](https://github.com/WordPress/gutenberg/pull/82367)). + ### Internal - Added an internal entity sync interface: `registerEntitySyncManager` and `getEntitySyncManager` let one manager plug into the entity lifecycle (record loads, edits, saves, deletes, and undo). The real-time collaboration experiment now runs through it instead of being called directly by the store. The interface is not exposed outside the package yet ([#83410](https://github.com/WordPress/gutenberg/pull/83410)). diff --git a/packages/core-data/src/entity-types/icon.ts b/packages/core-data/src/entity-types/icon.ts index 718d11e42232da..443e2086f1b625 100644 --- a/packages/core-data/src/entity-types/icon.ts +++ b/packages/core-data/src/entity-types/icon.ts @@ -21,6 +21,10 @@ declare module './base-entity-records' { * The slug of the collection this icon belongs to. */ collection?: string; + /** + * Additional search terms for the icon. + */ + keywords?: string[]; } } } diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index 3fc8c92131de42..3e7aacc88e7b92 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -11,6 +11,7 @@ - `formatBold`: draw the B with heavier vertical strokes so it reads as bold at a glance ([#83321](https://github.com/WordPress/gutenberg/pull/83321)). - `justifySpaceBetween` and `justifySpaceEvenly`: redraw so the two icons share one construction with the new `justifySpaceAround`, differing only in where the blocks sit ([#83519](https://github.com/WordPress/gutenberg/pull/83519)). - `headingLevel1` to `headingLevel6`: enlarge the drawings to improve their optical balance with the rest of the set ([#83268](https://github.com/WordPress/gutenberg/pull/83268)). +- `manifest.json`: Add optional `keywords` property to improve discoverability. ([#82367](https://github.com/WordPress/gutenberg/pull/82367)) ### Bug Fixes @@ -124,7 +125,7 @@ ### Breaking Changes -- Rename Tabs-related icons: `tabs-menu` to `tab-list`, `tabs-menu-item` to `tab`, and `tab` to `tab-panel`. ([#77418](https://github.com/WordPress/gutenberg/pull/77418)) +- Rename Tabs-related icons: `tabs-menu` to `tab-list`, `tabs-menu-item` to `tab`, and `tab` to `tab-panel`. ([#77418](https://github.com/WordPress/gutenberg/pull/77418)) ## 12.2.0 (2026-04-15) diff --git a/packages/icons/README.md b/packages/icons/README.md index bb0de5523aae89..9c574da14d31be 100644 --- a/packages/icons/README.md +++ b/packages/icons/README.md @@ -53,7 +53,7 @@ To add a new icon to the library, follow these steps: - `collections` (optional): The collections the icon is registered in once it ships to WordPress Core. Omit it to keep the icon in the JS library only. **Important**: Once an icon ships to WordPress Core, removing it is difficult, so carefully consider this field before setting it. The available collections are: - `core`: the public collection. Its icons are exposed through the SVG Icons API, which makes them selectable in the Icon block. - `core-admin`: the collection the WordPress admin interface renders from. It is not public, so its icons are never exposed through the REST API or selectable in the Icon block, and unregistering `core` cannot take them away from the admin interface. - + - `keywords` (optional): An array of additional translatable search terms for the icon (e.g. `[ "hamburger", "navigation" ]` for a Menu icon). 4. **Do not edit `manifest.php`**: The `manifest.php` file is automatically generated from `manifest.json` by the build script. Do not edit it manually, as your changes will be overwritten when the build runs. After adding your icon, run `npm run build` to generate the TypeScript files and update `manifest.php`. diff --git a/packages/icons/lib/generate-manifest-php.cjs b/packages/icons/lib/generate-manifest-php.cjs index ba4a4527ee8476..6e89ecd4b89879 100644 --- a/packages/icons/lib/generate-manifest-php.cjs +++ b/packages/icons/lib/generate-manifest-php.cjs @@ -46,10 +46,23 @@ function generatePHPArray( manifest ) { .map( ( collection ) => `'${ escapePHPString( collection ) }'` ) .join( ', ' ); + // Keywords are optional, and are localized like labels since they are + // matched against user-entered search terms. + const keywords = item.keywords?.length + ? `\n\t\t'keywords' => array( ${ item.keywords + .map( + ( keyword ) => + `_x( '${ escapePHPString( + keyword + ) }', 'icon keyword', 'gutenberg' )` + ) + .join( ', ' ) } ),` + : ''; + return `${ key } => array( 'label' => _x( '${ label }', 'icon label', 'gutenberg' ), 'filePath' => '${ filePath }', - 'collections' => array( ${ collections } ), + 'collections' => array( ${ collections } ),${ keywords } ),`; } ); diff --git a/packages/icons/lib/validate-collection.cjs b/packages/icons/lib/validate-collection.cjs index b75250f74e5f23..a01121d6dfead6 100644 --- a/packages/icons/lib/validate-collection.cjs +++ b/packages/icons/lib/validate-collection.cjs @@ -18,6 +18,7 @@ function isStrokeBasedSvg( svgContent ) { * - Each manifest entry has a matching SVG in library/, and vice versa. * - Each manifest entry's `collections` property, if present, is a non-empty array of * known collection slugs. + * - Each manifest entry's `keywords` property, if present, is an array of strings. * - Each SVG uses currentColor so icons inherit text color. * - Each SVG uses viewBox="0 0 24 24". * - Each stroke-based SVG contains at least one stroked graphical element. @@ -90,6 +91,25 @@ async function validateCollection() { } } + /* + * Verify that `keywords`, if present, is an array of strings. + */ + if ( + 'keywords' in icon && + ( ! Array.isArray( icon.keywords ) || + icon.keywords.some( + ( keyword ) => typeof keyword !== 'string' + ) ) + ) { + problems.push( + `- Invalid icon definition for icon '${ + icon.slug + }': expected 'keywords' to be an array of strings, saw ${ JSON.stringify( + icon.keywords + ) }` + ); + } + /* * Verify that the corresponding SVG file is found. */ diff --git a/phpunit/class-wp-icon-collections-registry-test.php b/phpunit/class-wp-icon-collections-registry-test.php index a4448c3177ac63..009fbcde389ef8 100644 --- a/phpunit/class-wp-icon-collections-registry-test.php +++ b/phpunit/class-wp-icon-collections-registry-test.php @@ -11,31 +11,48 @@ class WP_Test_Icon_Collections_Registry extends WP_UnitTestCase { */ protected $collections; + /** + * Icons registry instance in place before the test, restored in tear_down. + * + * @var WP_Icons_Registry|null + */ + private $original_icons_registry = null; + public function set_up() { parent::set_up(); $this->collections = WP_Icon_Collections_Registry::get_instance(); - } - - public function tear_down() { - foreach ( array( 'plugin-a', 'plugin-b', 'my-collection' ) as $slug ) { - if ( $this->collections->is_registered( $slug ) ) { - $this->collections->unregister( $slug ); - } - } - - $instance_property = new ReflectionProperty( WP_Icons_Registry_Gutenberg::class, 'instance' ); /* + * Start from a clean registry, keeping the instance that was in place so + * `tear_down()` can put it back rather than leaving later suites with a + * base registry that rejects Gutenberg-only icon properties. + * * ReflectionProperty::setAccessible is: * - redundant as of 8.1.0, which made all properties accessible * - deprecated as of 8.5.0 * - needed until 8.1.0, as property `instance` is private */ + $instance_property = new ReflectionProperty( WP_Icons_Registry_Gutenberg::class, 'instance' ); if ( PHP_VERSION_ID < 80100 ) { $instance_property->setAccessible( true ); } - + $this->original_icons_registry = $instance_property->getValue(); $instance_property->setValue( null, null ); + } + + public function tear_down() { + $instance_property = new ReflectionProperty( WP_Icons_Registry_Gutenberg::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_property->setAccessible( true ); + } + $instance_property->setValue( null, $this->original_icons_registry ); + $this->original_icons_registry = null; + + foreach ( array( 'plugin-a', 'plugin-b', 'my-collection' ) as $slug ) { + if ( $this->collections->is_registered( $slug ) ) { + $this->collections->unregister( $slug ); + } + } parent::tear_down(); } diff --git a/phpunit/class-wp-icon-test.php b/phpunit/class-wp-icon-test.php index 495d3712cc2855..984d541de44f5d 100644 --- a/phpunit/class-wp-icon-test.php +++ b/phpunit/class-wp-icon-test.php @@ -7,25 +7,6 @@ class Tests_Icons_WpGetIcon extends WP_UnitTestCase { - public function set_up() { - parent::set_up(); - - /* - * Other suites reset the `WP_Icons_Registry` singleton, wiping the collections and - * icons that `init` only registers once. Replay the registration so order-dependent - * tests pass. `gutenberg_register_default_icon_collections()` registers every default - * collection at once, so drop whatever survived rather than topping up. - */ - $collections_registry = WP_Icon_Collections_Registry::get_instance(); - foreach ( array( 'core', 'core-admin' ) as $collection_slug ) { - if ( $collections_registry->is_registered( $collection_slug ) ) { - $collections_registry->unregister( $collection_slug ); - } - } - gutenberg_register_default_icon_collections(); - gutenberg_register_default_icons(); - } - public function test_wp_get_icon_returns_svg_for_known_icon() { $output = wp_get_icon( 'core/plus' ); $this->assertStringStartsWith( 'setAccessible( true ); + } + $this->original_registry = $instance_property->getValue(); + $instance_property->setValue( null, null ); + $this->registry = WP_Icons_Registry_Gutenberg::get_instance(); $collections = WP_Icon_Collections_Registry::get_instance(); if ( ! $collections->is_registered( 'test-collection' ) ) { @@ -31,18 +56,11 @@ public function set_up() { public function tear_down() { $instance_property = new ReflectionProperty( WP_Icons_Registry_Gutenberg::class, 'instance' ); - - /* - * ReflectionProperty::setAccessible is: - * - redundant as of 8.1.0, which made all properties accessible - * - deprecated as of 8.5.0 - * - needed until 8.1.0, as property `instance` is private - */ if ( PHP_VERSION_ID < 80100 ) { $instance_property->setAccessible( true ); } - - $instance_property->setValue( null, null ); + $instance_property->setValue( null, $this->original_registry ); + $this->original_registry = null; $collections = WP_Icon_Collections_Registry::get_instance(); if ( $collections->is_registered( 'test-collection' ) ) { @@ -522,4 +540,169 @@ public function test_sanitize_icon_content_preserves_library_icons( string $file $content = file_get_contents( $file_path ); $this->assertEqualHTML( $content, $this->sanitize_icon_content( $content ) ); } + + /** + * Should register an icon that provides optional args like a valid `keywords` array. + */ + public function test_register_icon_with_optional_args() { + $name = 'test-collection/with-keywords'; + + $result = $this->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'] ); + } + + /** + * 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. + * + * @dataProvider data_invalid_keywords + * @expectedIncorrectUsage WP_Icons_Registry_Gutenberg::register + * + * @param mixed $keywords Invalid keywords candidate. + */ + public function test_register_icon_with_invalid_keywords( $keywords ) { + $name = 'test-collection/invalid-keywords'; + + $result = $this->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. + */ + public function test_get_registered_icons_matches_keywords() { + $this->register( + 'test-collection/dove', + array( + 'label' => 'Dove', + 'content' => '', + 'keywords' => array( 'peace' ), + ) + ); + $this->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. + */ + public function test_get_registered_icons_matches_keywords_case_insensitively() { + $this->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 ); + } + + /** + * Should not drop a third-party icon's keywords when an existing base registry + * is upgraded to the Gutenberg registry. + */ + public function test_get_instance_preserves_keywords_when_upgrading_base_registry() { + // Build a base registry without invoking the constructor, which would + // register the core icons from the core manifest. + $base = ( new ReflectionClass( WP_Icons_Registry::class ) )->newInstanceWithoutConstructor(); + + $icons_property = new ReflectionProperty( WP_Icons_Registry::class, 'registered_icons' ); + if ( PHP_VERSION_ID < 80100 ) { + $icons_property->setAccessible( true ); + } + $icons_property->setValue( + $base, + array( + 'test-collection/dove' => array( + 'name' => 'test-collection/dove', + 'collection' => 'test-collection', + 'label' => 'Dove', + 'content' => '', + 'keywords' => array( 'peace' ), + ), + ) + ); + + $instance_property = new ReflectionProperty( WP_Icons_Registry_Gutenberg::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_property->setAccessible( true ); + } + $instance_property->setValue( null, $base ); + + $upgraded = WP_Icons_Registry_Gutenberg::get_instance(); + + $this->assertInstanceOf( WP_Icons_Registry_Gutenberg::class, $upgraded ); + + $icon = $upgraded->get_registered_icon( 'test-collection/dove' ); + $this->assertSame( array( 'peace' ), $icon['keywords'] ); + + $names = array_column( $upgraded->get_registered_icons( 'peace' ), 'name' ); + $this->assertContains( 'test-collection/dove', $names ); + } } diff --git a/phpunit/class-wp-rest-icon-controller-test.php b/phpunit/class-wp-rest-icon-controller-test.php index cd9db70f7ca517..709e62f10d2169 100644 --- a/phpunit/class-wp-rest-icon-controller-test.php +++ b/phpunit/class-wp-rest-icon-controller-test.php @@ -27,21 +27,6 @@ public static function wpTearDownAfterClass() { public function set_up() { parent::set_up(); - /* - * Other suites reset the `WP_Icons_Registry` singleton, wiping the collections and - * icons that `init` only registers once. Replay the registration so order-dependent - * tests pass. `gutenberg_register_default_icon_collections()` registers every default - * collection at once, so drop whatever survived rather than topping up. - */ - $collections_registry = WP_Icon_Collections_Registry::get_instance(); - foreach ( array( 'core', 'core-admin' ) as $collection_slug ) { - if ( $collections_registry->is_registered( $collection_slug ) ) { - $collections_registry->unregister( $collection_slug ); - } - } - gutenberg_register_default_icon_collections(); - gutenberg_register_default_icons(); - $collections = array( 'test-public' => true, 'test-private' => false, @@ -213,6 +198,103 @@ public function test_get_items_search_includes_label() { $this->assertEquals( array( 'core/at-symbol' ), array_column( $data, 'name' ) ); } + /** + * Registers an icon carrying keywords, so the keyword tests do not depend on + * the terms any bundled icon happens to ship with. + * + * @return string The registered icon name. + */ + private function register_keyword_icon() { + $icon_name = 'core/keyword-icon'; + + wp_register_icon( + $icon_name, + array( + 'label' => 'Keyword Icon', + 'content' => '', + 'keywords' => array( 'hamburger' ), + ) + ); + + return $icon_name; + } + + /** + * Test that GET /wp/v2/icons/?search=%s searches icon keywords too. + */ + public function test_get_items_search_includes_keywords() { + wp_set_current_user( self::$editor_id ); + + $icon_name = $this->register_keyword_icon(); + + try { + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + + // 'hamburger' is in neither the name nor the label, only the keywords. + $request->set_param( 'search', 'hamburger' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEquals( array( $icon_name ), array_column( $data, 'name' ) ); + } finally { + wp_unregister_icon( $icon_name ); + } + } + + /** + * Test that the response exposes an icon's keywords, so that clients which + * filter icons locally can match against them. + */ + public function test_get_items_response_includes_keywords() { + wp_set_current_user( self::$editor_id ); + + $icon_name = $this->register_keyword_icon(); + + try { + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $request->set_param( 'search', $icon_name ); + $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->assertContains( 'hamburger', $data[0]['keywords'] ); + } finally { + wp_unregister_icon( $icon_name ); + } + } + + /** + * Test that icons registered without keywords still expose an empty array, + * so consumers do not have to handle a missing field. + */ + 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. */