From a807e86391817a45dd82f54bef234be1a28d4c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=83=C2=B3=C3=85=E2=80=9Akowski?= Date: Tue, 12 Apr 2022 09:24:51 +0000 Subject: [PATCH] REST API: Bring new endpoints for Block Patterns from Gutenberg plugin Related Gutenberg issue: https://github.com/WordPress/gutenberg/issues/39889. Backporting changes from the Gutenberg plugin: - new Block Patterns REST API endpoint - new Block Pattern Categories REST API endpoint - updates to Query Loop related patterns - support for custom taxonomies in Query Loop block Props hellofromtonya, peterwilsoncc, ntsekouras, zieladam, ironprogrammer, spacedmonkey, timothyblynjacobs, antonvlasenko, jsnajdr. See #55505. git-svn-id: https://develop.svn.wordpress.org/trunk@53152 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/block-patterns.php | 214 +++++++++++++++++- .../block-patterns/query-grid-posts.php | 2 +- .../query-large-title-posts.php | 2 +- .../block-patterns/query-medium-posts.php | 2 +- .../block-patterns/query-offset-posts.php | 4 +- .../block-patterns/query-small-posts.php | 2 +- .../block-patterns/query-standard-posts.php | 2 +- src/wp-includes/blocks.php | 40 +++- src/wp-includes/rest-api.php | 8 + ...st-block-pattern-categories-controller.php | 150 ++++++++++++ ...lass-wp-rest-block-patterns-controller.php | 198 ++++++++++++++++ ...s-wp-rest-pattern-directory-controller.php | 22 +- src/wp-settings.php | 2 + tests/phpunit/tests/blocks/wpBlock.php | 14 +- .../rest-pattern-directory-controller.php | 2 +- .../tests/rest-api/rest-schema-setup.php | 2 + ...wpRestBlockPatternCategoriesController.php | 175 ++++++++++++++ .../wpRestBlockPatternsController.php | 201 ++++++++++++++++ tests/qunit/fixtures/wp-api-generated.js | 45 +++- 19 files changed, 1057 insertions(+), 30 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php create mode 100644 tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php create mode 100644 tests/phpunit/tests/rest-api/wpRestBlockPatternsController.php diff --git a/src/wp-includes/block-patterns.php b/src/wp-includes/block-patterns.php index 0d4eacd1a32aa..0498db7e01f81 100644 --- a/src/wp-includes/block-patterns.php +++ b/src/wp-includes/block-patterns.php @@ -12,7 +12,7 @@ * Registers the core block patterns and categories. * * @since 5.5.0 - * @private + * @access private */ function _register_core_block_patterns_and_categories() { $should_register_core_patterns = get_theme_support( 'core-block-patterns' ); @@ -127,3 +127,215 @@ function _load_remote_featured_patterns() { } } } + +/** + * Registers patterns from Pattern Directory provided by a theme's + * `theme.json` file. + * + * @since 6.0.0 + * @access private + */ +function _register_remote_theme_patterns() { + if ( ! get_theme_support( 'core-block-patterns' ) ) { + return; + } + + /** This filter is documented in wp-includes/block-patterns.php */ + if ( ! apply_filters( 'should_load_remote_block_patterns', true ) ) { + return; + } + + if ( ! WP_Theme_JSON_Resolver::theme_has_support() ) { + return; + } + + $pattern_settings = WP_Theme_JSON_Resolver::get_theme_data()->get_patterns(); + if ( empty( $pattern_settings ) ) { + return; + } + + $request = new WP_REST_Request( 'GET', '/wp/v2/pattern-directory/patterns' ); + $request['slug'] = implode( ',', $pattern_settings ); + $response = rest_do_request( $request ); + if ( $response->is_error() ) { + return; + } + $patterns = $response->get_data(); + $patterns_registry = WP_Block_Patterns_Registry::get_instance(); + foreach ( $patterns as $pattern ) { + $pattern_name = sanitize_title( $pattern['title'] ); + // Some patterns might be already registered as core patterns with the `core` prefix. + $is_registered = $patterns_registry->is_registered( $pattern_name ) || $patterns_registry->is_registered( "core/$pattern_name" ); + if ( ! $is_registered ) { + register_block_pattern( $pattern_name, (array) $pattern ); + } + } +} + +/** + * Register any patterns that the active theme may provide under its + * `./patterns/` directory. Each pattern is defined as a PHP file and defines + * its metadata using plugin-style headers. The minimum required definition is: + * + * /** + * * Title: My Pattern + * * Slug: my-theme/my-pattern + * * + * + * The output of the PHP source corresponds to the content of the pattern, e.g.: + * + *

+ * + * If applicable, this will collect from both parent and child theme. + * + * Other settable fields include: + * + * - Description + * - Viewport Width + * - Categories (comma-separated values) + * - Keywords (comma-separated values) + * - Block Types (comma-separated values) + * - Inserter (yes/no) + * + * @since 6.0.0 + * @access private + */ +function _register_theme_block_patterns() { + $default_headers = array( + 'title' => 'Title', + 'slug' => 'Slug', + 'description' => 'Description', + 'viewportWidth' => 'Viewport Width', + 'categories' => 'Categories', + 'keywords' => 'Keywords', + 'blockTypes' => 'Block Types', + 'inserter' => 'Inserter', + ); + + /* + * Register patterns for the active theme. If the theme is a child theme, + * let it override any patterns from the parent theme that shares the same slug. + */ + $themes = array(); + $stylesheet = get_stylesheet(); + $template = get_template(); + if ( $stylesheet !== $template ) { + $themes[] = wp_get_theme( $stylesheet ); + } + $themes[] = wp_get_theme( $template ); + + foreach ( $themes as $theme ) { + $dirpath = $theme->get_stylesheet_directory() . '/patterns/'; + if ( ! is_dir( $dirpath ) || ! is_readable( $dirpath ) ) { + continue; + } + if ( file_exists( $dirpath ) ) { + $files = glob( $dirpath . '*.php' ); + if ( $files ) { + foreach ( $files as $file ) { + $pattern_data = get_file_data( $file, $default_headers ); + + if ( empty( $pattern_data['slug'] ) ) { + _doing_it_wrong( + '_register_theme_block_patterns', + sprintf( + /* translators: %s: file name. */ + __( 'Could not register file "%s" as a block pattern ("Slug" field missing)' ), + $file + ), + '6.0.0' + ); + continue; + } + + if ( ! preg_match( '/^[A-z0-9\/_-]+$/', $pattern_data['slug'] ) ) { + _doing_it_wrong( + '_register_theme_block_patterns', + sprintf( + /* translators: %1s: file name; %2s: slug value found. */ + __( 'Could not register file "%1$s" as a block pattern (invalid slug "%2$s")' ), + $file, + $pattern_data['slug'] + ), + '6.0.0' + ); + } + + if ( WP_Block_Patterns_Registry::get_instance()->is_registered( $pattern_data['slug'] ) ) { + continue; + } + + // Title is a required property. + if ( ! $pattern_data['title'] ) { + _doing_it_wrong( + '_register_theme_block_patterns', + sprintf( + /* translators: %1s: file name; %2s: slug value found. */ + __( 'Could not register file "%s" as a block pattern ("Title" field missing)' ), + $file + ), + '6.0.0' + ); + continue; + } + + // For properties of type array, parse data as comma-separated. + foreach ( array( 'categories', 'keywords', 'blockTypes' ) as $property ) { + if ( ! empty( $pattern_data[ $property ] ) ) { + $pattern_data[ $property ] = array_filter( + preg_split( + '/[\s,]+/', + (string) $pattern_data[ $property ] + ) + ); + } else { + unset( $pattern_data[ $property ] ); + } + } + + // Parse properties of type int. + foreach ( array( 'viewportWidth' ) as $property ) { + if ( ! empty( $pattern_data[ $property ] ) ) { + $pattern_data[ $property ] = (int) $pattern_data[ $property ]; + } else { + unset( $pattern_data[ $property ] ); + } + } + + // Parse properties of type bool. + foreach ( array( 'inserter' ) as $property ) { + if ( ! empty( $pattern_data[ $property ] ) ) { + $pattern_data[ $property ] = in_array( + strtolower( $pattern_data[ $property ] ), + array( 'yes', 'true' ), + true + ); + } else { + unset( $pattern_data[ $property ] ); + } + } + + // Translate the pattern metadata. + $text_domain = $theme->get( 'TextDomain' ); + //phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.NonSingularStringLiteralContext, WordPress.WP.I18n.NonSingularStringLiteralDomain, WordPress.WP.I18n.LowLevelTranslationFunction + $pattern_data['title'] = translate_with_gettext_context( $pattern_data['title'], 'Pattern title', $text_domain ); + if ( ! empty( $pattern_data['description'] ) ) { + //phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.NonSingularStringLiteralContext, WordPress.WP.I18n.NonSingularStringLiteralDomain, WordPress.WP.I18n.LowLevelTranslationFunction + $pattern_data['description'] = translate_with_gettext_context( $pattern_data['description'], 'Pattern description', $text_domain ); + } + + // The actual pattern content is the output of the file. + ob_start(); + include $file; + $pattern_data['content'] = ob_get_clean(); + if ( ! $pattern_data['content'] ) { + continue; + } + + register_block_pattern( $pattern_data['slug'], $pattern_data ); + } + } + } + } +} +add_action( 'init', '_register_theme_block_patterns' ); diff --git a/src/wp-includes/block-patterns/query-grid-posts.php b/src/wp-includes/block-patterns/query-grid-posts.php index 50e707a88b6b3..27a0b1d6a7f27 100644 --- a/src/wp-includes/block-patterns/query-grid-posts.php +++ b/src/wp-includes/block-patterns/query-grid-posts.php @@ -9,7 +9,7 @@ 'title' => _x( 'Grid', 'Block pattern title' ), 'blockTypes' => array( 'core/query' ), 'categories' => array( 'query' ), - 'content' => ' + 'content' => '
diff --git a/src/wp-includes/block-patterns/query-large-title-posts.php b/src/wp-includes/block-patterns/query-large-title-posts.php index f2d09e69872f8..0adf63acedf06 100644 --- a/src/wp-includes/block-patterns/query-large-title-posts.php +++ b/src/wp-includes/block-patterns/query-large-title-posts.php @@ -10,7 +10,7 @@ 'blockTypes' => array( 'core/query' ), 'categories' => array( 'query' ), 'content' => ' -
+

diff --git a/src/wp-includes/block-patterns/query-medium-posts.php b/src/wp-includes/block-patterns/query-medium-posts.php index bd564ebe43a20..125ffd3010f83 100644 --- a/src/wp-includes/block-patterns/query-medium-posts.php +++ b/src/wp-includes/block-patterns/query-medium-posts.php @@ -9,7 +9,7 @@ 'title' => _x( 'Image at left', 'Block pattern title' ), 'blockTypes' => array( 'core/query' ), 'categories' => array( 'query' ), - 'content' => ' + 'content' => '
diff --git a/src/wp-includes/block-patterns/query-offset-posts.php b/src/wp-includes/block-patterns/query-offset-posts.php index 34eeb0efac9fe..139a4cb7f0c6a 100644 --- a/src/wp-includes/block-patterns/query-offset-posts.php +++ b/src/wp-includes/block-patterns/query-offset-posts.php @@ -12,7 +12,7 @@ 'content' => '
-
+
@@ -24,7 +24,7 @@
-
+
diff --git a/src/wp-includes/block-patterns/query-small-posts.php b/src/wp-includes/block-patterns/query-small-posts.php index 2f165c1b8ba93..66d57fcf68c29 100644 --- a/src/wp-includes/block-patterns/query-small-posts.php +++ b/src/wp-includes/block-patterns/query-small-posts.php @@ -9,7 +9,7 @@ 'title' => _x( 'Small image and title', 'Block pattern title' ), 'blockTypes' => array( 'core/query' ), 'categories' => array( 'query' ), - 'content' => ' + 'content' => '
diff --git a/src/wp-includes/block-patterns/query-standard-posts.php b/src/wp-includes/block-patterns/query-standard-posts.php index c05ace98db93c..c6fde304e7edf 100644 --- a/src/wp-includes/block-patterns/query-standard-posts.php +++ b/src/wp-includes/block-patterns/query-standard-posts.php @@ -9,7 +9,7 @@ 'title' => _x( 'Standard', 'Block pattern title' ), 'blockTypes' => array( 'core/query' ), 'categories' => array( 'query' ), - 'content' => ' + 'content' => '
diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index dea94f173ec22..3be427e866c0b 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -955,9 +955,8 @@ function do_blocks( $content ) { * If do_blocks() needs to remove wpautop() from the `the_content` filter, this re-adds it afterwards, * for subsequent `the_content` usage. * - * @access private - * * @since 5.0.0 + * @access private * * @param string $content The post content running through this filter. * @return string The unmodified content. @@ -1142,15 +1141,36 @@ function build_query_vars_from_query_block( $block, $page ) { $query['offset'] = ( $per_page * ( $page - 1 ) ) + $offset; $query['posts_per_page'] = $per_page; } - if ( ! empty( $block->context['query']['categoryIds'] ) ) { - $term_ids = array_map( 'intval', $block->context['query']['categoryIds'] ); - $term_ids = array_filter( $term_ids ); - $query['category__in'] = $term_ids; + // Migrate `categoryIds` and `tagIds` to `tax_query` for backwards compatibility. + if ( ! empty( $block->context['query']['categoryIds'] ) || ! empty( $block->context['query']['tagIds'] ) ) { + $tax_query = array(); + if ( ! empty( $block->context['query']['categoryIds'] ) ) { + $tax_query[] = array( + 'taxonomy' => 'category', + 'terms' => array_filter( array_map( 'intval', $block->context['query']['categoryIds'] ) ), + 'include_children' => false, + ); + } + if ( ! empty( $block->context['query']['tagIds'] ) ) { + $tax_query[] = array( + 'taxonomy' => 'post_tag', + 'terms' => array_filter( array_map( 'intval', $block->context['query']['tagIds'] ) ), + 'include_children' => false, + ); + } + $query['tax_query'] = $tax_query; } - if ( ! empty( $block->context['query']['tagIds'] ) ) { - $term_ids = array_map( 'intval', $block->context['query']['tagIds'] ); - $term_ids = array_filter( $term_ids ); - $query['tag__in'] = $term_ids; + if ( ! empty( $block->context['query']['taxQuery'] ) ) { + $query['tax_query'] = array(); + foreach ( $block->context['query']['taxQuery'] as $taxonomy => $terms ) { + if ( is_taxonomy_viewable( $taxonomy ) && ! empty( $terms ) ) { + $query['tax_query'][] = array( + 'taxonomy' => $taxonomy, + 'terms' => array_filter( array_map( 'intval', $terms ) ), + 'include_children' => false, + ); + } + } } if ( isset( $block->context['query']['order'] ) && diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a7cd0178b0a42..941aa25f12066 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -337,6 +337,14 @@ function create_initial_rest_routes() { $controller = new WP_REST_Pattern_Directory_Controller(); $controller->register_routes(); + // Block Patterns. + $controller = new WP_REST_Block_Patterns_Controller(); + $controller->register_routes(); + + // Block Pattern Categories. + $controller = new WP_REST_Block_Pattern_Categories_Controller(); + $controller->register_routes(); + // Site Health. $site_health = WP_Site_Health::get_instance(); $controller = new WP_REST_Site_Health_Controller( $site_health ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php new file mode 100644 index 0000000000000..693bb7514f8ba --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php @@ -0,0 +1,150 @@ +namespace = 'wp/v2'; + $this->rest_base = 'block-patterns/categories'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 6.0.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether a given request has permission to read block patterns. + * + * @since 6.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( current_user_can( 'edit_posts' ) ) { + return true; + } + + foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) { + if ( current_user_can( $post_type->cap->edit_posts ) ) { + return true; + } + } + + return new WP_Error( + 'rest_cannot_view', + __( 'Sorry, you are not allowed to view the registered block pattern categories.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Retrieves all block pattern categories. + * + * @since 6.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $response = array(); + $categories = WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered(); + foreach ( $categories as $category ) { + $prepared_category = $this->prepare_item_for_response( $category, $request ); + $response[] = $this->prepare_response_for_collection( $prepared_category ); + } + + return rest_ensure_response( $response ); + } + + /** + * Prepare a raw block pattern category before it gets output in a REST API response. + * + * @since 6.0.0 + * + * @param object $item Raw category as registered, before any changes. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $keys = array( 'name', 'label' ); + $data = array(); + foreach ( $keys as $key ) { + if ( rest_is_field_included( $key, $fields ) ) { + $data[ $key ] = $item[ $key ]; + } + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the block pattern category schema, conforming to JSON Schema. + * + * @since 6.0.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'block-pattern-category', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'The category name.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'label' => array( + 'description' => __( 'The category label, in human readable format.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php new file mode 100644 index 0000000000000..a96aa9e98d614 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php @@ -0,0 +1,198 @@ +namespace = 'wp/v2'; + $this->rest_base = 'block-patterns/patterns'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 6.0.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether a given request has permission to read block patterns. + * + * @since 6.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( current_user_can( 'edit_posts' ) ) { + return true; + } + + foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) { + if ( current_user_can( $post_type->cap->edit_posts ) ) { + return true; + } + } + + return new WP_Error( + 'rest_cannot_view', + __( 'Sorry, you are not allowed to view the registered block patterns.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Retrieves all block patterns. + * + * @since 6.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + // Load block patterns from w.org. + _load_remote_block_patterns(); // Patterns with the `core` keyword. + _load_remote_featured_patterns(); // Patterns in the `featured` category. + _register_remote_theme_patterns(); // Patterns requested by current theme. + + $response = array(); + $patterns = WP_Block_Patterns_Registry::get_instance()->get_all_registered(); + foreach ( $patterns as $pattern ) { + $prepared_pattern = $this->prepare_item_for_response( $pattern, $request ); + $response[] = $this->prepare_response_for_collection( $prepared_pattern ); + } + return rest_ensure_response( $response ); + } + + /** + * Prepare a raw block pattern before it gets output in a REST API response. + * + * @since 6.0.0 + * + * @param object $item Raw pattern as registered, before any changes. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $keys = array( + 'name', + 'title', + 'description', + 'viewportWidth', + 'blockTypes', + 'categories', + 'keywords', + 'content', + ); + $data = array(); + foreach ( $keys as $key ) { + if ( isset( $item[ $key ] ) && rest_is_field_included( $key, $fields ) ) { + $data[ $key ] = $item[ $key ]; + } + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + return rest_ensure_response( $data ); + } + + /** + * Retrieves the block pattern schema, conforming to JSON Schema. + * + * @since 6.0.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'block-pattern', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'The pattern name.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'title' => array( + 'description' => __( 'The pattern title, in human readable format.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'description' => array( + 'description' => __( 'The pattern detailed description.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'viewportWidth' => array( + 'description' => __( 'The pattern viewport width for inserter preview.' ), + 'type' => 'number', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'blockTypes' => array( + 'description' => __( 'Block types that the pattern is intended to be used with.' ), + 'type' => 'array', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'categories' => array( + 'description' => __( 'The pattern category slugs.' ), + 'type' => 'array', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'keywords' => array( + 'description' => __( 'The pattern keywords.' ), + 'type' => 'array', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'content' => array( + 'description' => __( 'The pattern content.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php index 186387d8c03cc..2deb3c7e7a387 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php @@ -80,6 +80,7 @@ public function get_items_permissions_check( $request ) { * Search and retrieve block patterns metadata * * @since 5.8.0 + * @since 6.0.0 Added 'slug' to request. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -100,6 +101,7 @@ public function get_items( $request ) { $category_id = $request['category']; $keyword_id = $request['keyword']; $search_term = $request['search']; + $slug = $request['slug']; if ( $category_id ) { $query_args['pattern-categories'] = $category_id; @@ -113,6 +115,10 @@ public function get_items( $request ) { $query_args['search'] = $search_term; } + if ( $slug ) { + $query_args['slug'] = $slug; + } + /* * Include a hash of the query args, so that different requests are stored in * separate caches. @@ -159,7 +165,7 @@ public function get_items( $request ) { $raw_patterns = new WP_Error( 'pattern_api_failed', sprintf( - /* translators: %s: Support forums URL. */ + /* translators: %s: Support forums URL. */ __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server’s configuration. If you continue to have problems, please try the support forums.' ), __( 'https://wordpress.org/support/forums/' ) ), @@ -255,21 +261,21 @@ public function get_item_schema() { 'description' => __( 'The pattern ID.' ), 'type' => 'integer', 'minimum' => 1, - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), 'title' => array( 'description' => __( 'The pattern title, in human readable format.' ), 'type' => 'string', 'minLength' => 1, - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), 'content' => array( 'description' => __( 'The pattern content.' ), 'type' => 'string', 'minLength' => 1, - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), 'categories' => array( @@ -277,7 +283,7 @@ public function get_item_schema() { 'type' => 'array', 'uniqueItems' => true, 'items' => array( 'type' => 'string' ), - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), 'keywords' => array( @@ -285,20 +291,20 @@ public function get_item_schema() { 'type' => 'array', 'uniqueItems' => true, 'items' => array( 'type' => 'string' ), - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), 'description' => array( 'description' => __( 'A description of the pattern.' ), 'type' => 'string', 'minLength' => 1, - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), 'viewport_width' => array( 'description' => __( 'The preferred width of the viewport when previewing a pattern, in pixels.' ), 'type' => 'integer', - 'context' => array( 'view', 'embed' ), + 'context' => array( 'view', 'edit', 'embed' ), ), ), ); diff --git a/src/wp-settings.php b/src/wp-settings.php index c1bab688c08a9..ffe047b1ec9fb 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -277,6 +277,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-directory-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-patterns-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-application-passwords-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-site-health-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-sidebars-controller.php'; diff --git a/tests/phpunit/tests/blocks/wpBlock.php b/tests/phpunit/tests/blocks/wpBlock.php index eb3fd1b769189..d329a65273362 100644 --- a/tests/phpunit/tests/blocks/wpBlock.php +++ b/tests/phpunit/tests/blocks/wpBlock.php @@ -443,8 +443,18 @@ public function test_build_query_vars_from_query_block() { 'order' => 'DESC', 'orderby' => 'title', 'post__not_in' => array( 1, 2 ), - 'category__in' => array( 56 ), - 'tag__in' => array( 3, 11, 10 ), + 'tax_query' => array( + array( + 'taxonomy' => 'category', + 'terms' => array( 56 ), + 'include_children' => false, + ), + array( + 'taxonomy' => 'post_tag', + 'terms' => array( 3, 11, 10 ), + 'include_children' => false, + ), + ), ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-pattern-directory-controller.php b/tests/phpunit/tests/rest-api/rest-pattern-directory-controller.php index 4a12e663b5392..53a2e0b3646b9 100644 --- a/tests/phpunit/tests/rest-api/rest-pattern-directory-controller.php +++ b/tests/phpunit/tests/rest-api/rest-pattern-directory-controller.php @@ -79,7 +79,7 @@ public function test_context_param() { $patterns = $response->get_data(); $this->assertSame( 'view', $patterns['endpoints'][0]['args']['context']['default'] ); - $this->assertSame( array( 'view', 'embed' ), $patterns['endpoints'][0]['args']['context']['enum'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $patterns['endpoints'][0]['args']['context']['enum'] ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 1a9347b4cabaa..d77c5d8aff903 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -161,6 +161,8 @@ public function test_expected_routes_in_schema() { '/wp/v2/plugins', '/wp/v2/plugins/(?P[^.\/]+(?:\/[^.\/]+)?)', '/wp/v2/block-directory/search', + '/wp/v2/block-patterns/categories', + '/wp/v2/block-patterns/patterns', '/wp/v2/sidebars', '/wp/v2/sidebars/(?P[\w-]+)', '/wp/v2/widget-types', diff --git a/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php b/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php new file mode 100644 index 0000000000000..b69145fdcfcd5 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php @@ -0,0 +1,175 @@ +user->create( array( 'role' => 'administrator' ) ); + + // Setup an empty testing instance of `WP_Block_Pattern_Categories_Registry` and save the original. + self::$orig_registry = WP_Block_Pattern_Categories_Registry::get_instance(); + self::$registry_instance_property = new ReflectionProperty( 'WP_Block_Pattern_Categories_Registry', 'instance' ); + self::$registry_instance_property->setAccessible( true ); + $test_registry = new WP_Block_Pattern_Categories_Registry(); + self::$registry_instance_property->setValue( $test_registry ); + + // Register some categories in the test registry. + $test_registry->register( 'test', array( 'label' => 'Test' ) ); + $test_registry->register( 'query', array( 'label' => 'Query' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + + // Restore the original registry instance. + self::$registry_instance_property->setValue( self::$orig_registry ); + self::$registry_instance_property->setAccessible( false ); + self::$registry_instance_property = null; + self::$orig_registry = null; + } + + public function set_up() { + parent::set_up(); + + switch_theme( 'emptytheme' ); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( static::REQUEST_ROUTE, $routes ); + } + + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + $expected_names = array( 'test', 'query' ); + $expected_fields = array( 'name', 'label' ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request['_fields'] = 'name,label'; + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( count( $expected_names ), $data ); + foreach ( $data as $idx => $item ) { + $this->assertSame( $expected_names[ $idx ], $item['name'] ); + $this->assertSame( $expected_fields, array_keys( $item ) ); + } + } + + /** + * Verify capability check for unauthorized request (not logged in). + */ + public function test_get_items_unauthorized() { + // Ensure current user is logged out. + wp_logout(); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $response = rest_do_request( $request ); + + $this->assertWPError( $response->as_error() ); + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Verify capability check for forbidden request (insufficient capability). + */ + public function test_get_items_forbidden() { + // Set current user without `edit_posts` capability. + wp_set_current_user( $this->factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $response = rest_do_request( $request ); + + $this->assertWPError( $response->as_error() ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_context_param() { + $this->markTestSkipped( 'Controller does not use context_param.' ); + } + + public function test_get_item() { + $this->markTestSkipped( 'Controller does not have get_item route.' ); + } + + public function test_create_item() { + $this->markTestSkipped( 'Controller does not have create_item route.' ); + } + + public function test_update_item() { + $this->markTestSkipped( 'Controller does not have update_item route.' ); + } + + public function test_delete_item() { + $this->markTestSkipped( 'Controller does not have delete_item route.' ); + } + + public function test_prepare_item() { + $this->markTestSkipped( 'Controller does not have prepare_item route.' ); + } + + public function test_get_item_schema() { + $this->markTestSkipped( 'Controller does not have get_item_schema route.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestBlockPatternsController.php b/tests/phpunit/tests/rest-api/wpRestBlockPatternsController.php new file mode 100644 index 0000000000000..627a9262a7d74 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestBlockPatternsController.php @@ -0,0 +1,201 @@ +user->create( array( 'role' => 'administrator' ) ); + + // Setup an empty testing instance of `WP_Block_Patterns_Registry` and save the original. + self::$orig_registry = WP_Block_Patterns_Registry::get_instance(); + self::$registry_instance_property = new ReflectionProperty( 'WP_Block_Patterns_Registry', 'instance' ); + self::$registry_instance_property->setAccessible( true ); + $test_registry = new WP_Block_Pattern_Categories_Registry(); + self::$registry_instance_property->setValue( $test_registry ); + + // Register some patterns in the test registry. + $test_registry->register( + 'test/one', + array( + 'title' => 'Pattern One', + 'categories' => array( 'test' ), + 'viewportWidth' => 1440, + 'content' => '

One

', + ) + ); + + $test_registry->register( + 'test/two', + array( + 'title' => 'Pattern Two', + 'categories' => array( 'test' ), + 'content' => '

Two

', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + + // Restore the original registry instance. + self::$registry_instance_property->setValue( self::$orig_registry ); + self::$registry_instance_property->setAccessible( false ); + self::$registry_instance_property = null; + self::$orig_registry = null; + } + + public function set_up() { + parent::set_up(); + + switch_theme( 'emptytheme' ); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( static::REQUEST_ROUTE, $routes ); + } + + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request['_fields'] = 'name,content'; + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertIsArray( $data, 'WP_REST_Block_Patterns_Controller::get_items() should return an array' ); + $this->assertGreaterThanOrEqual( 2, count( $data ), 'WP_REST_Block_Patterns_Controller::get_items() should return at least 2 items' ); + $this->assertSame( + array( + 'name' => 'test/one', + 'content' => '

One

', + ), + $data[0], + 'WP_REST_Block_Patterns_Controller::get_items() should return test/one' + ); + $this->assertSame( + array( + 'name' => 'test/two', + 'content' => '

Two

', + ), + $data[1], + 'WP_REST_Block_Patterns_Controller::get_items() should return test/two' + ); + } + + /** + * Verify capability check for unauthorized request (not logged in). + */ + public function test_get_items_unauthorized() { + // Ensure current user is logged out. + wp_logout(); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $response = rest_do_request( $request ); + + $this->assertWPError( $response->as_error() ); + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Verify capability check for forbidden request (insufficient capability). + */ + public function test_get_items_forbidden() { + // Set current user without `edit_posts` capability. + wp_set_current_user( $this->factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $response = rest_do_request( $request ); + + $this->assertWPError( $response->as_error() ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_context_param() { + $this->markTestSkipped( 'Controller does not use context_param.' ); + } + + public function test_get_item() { + $this->markTestSkipped( 'Controller does not have get_item route.' ); + } + + public function test_create_item() { + $this->markTestSkipped( 'Controller does not have create_item route.' ); + } + + public function test_update_item() { + $this->markTestSkipped( 'Controller does not have update_item route.' ); + } + + public function test_delete_item() { + $this->markTestSkipped( 'Controller does not have delete_item route.' ); + } + + public function test_prepare_item() { + $this->markTestSkipped( 'Controller does not have prepare_item route.' ); + } + + public function test_get_item_schema() { + $this->markTestSkipped( 'Controller does not have get_item_schema route.' ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 74504826d1dc2..c8874649d707d 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -10362,7 +10362,8 @@ mockedApiResponse.Schema = { "type": "string", "enum": [ "view", - "embed" + "embed", + "edit" ], "default": "view", "required": false @@ -10396,6 +10397,48 @@ mockedApiResponse.Schema = { ] } }, + "/wp/v2/block-patterns/patterns": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": [] + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/block-patterns/patterns" + } + ] + } + }, + "/wp/v2/block-patterns/categories": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": [] + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/block-patterns/categories" + } + ] + } + }, "/wp-site-health/v1": { "namespace": "wp-site-health/v1", "methods": [