diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-migrate-block-patterns b/projects/packages/jetpack-mu-wpcom/changelog/add-migrate-block-patterns new file mode 100644 index 0000000000000..f59ebd91e770d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-migrate-block-patterns @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Migrate Block Patterns diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index d535f7d031db3..ee264a3b98803 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -66,6 +66,8 @@ public static function load_features() { require_once __DIR__ . '/features/100-year-plan/locked-mode.php'; require_once __DIR__ . '/features/media/heif-support.php'; + + require_once __DIR__ . '/features/block-patterns/block-patterns.php'; } /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/README.md b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/README.md new file mode 100644 index 0000000000000..41714edd76554 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/README.md @@ -0,0 +1,13 @@ +# Block Patterns + +## Adding a new pattern + +Please add any new block pattern to the `patterns` folder within this module. +Every file in that folder gets automatically included registered as a block pattern. + +There are two ways to add a new pattern, depending on whether it contains text or images that need to be localized: + +1. A json file for static block patterns. +1. A php file for dynamic block patterns that need localization. + +Please refer the Image and Description pattern as an example for a dynamic pattern that needs translation. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/block-patterns.php b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/block-patterns.php new file mode 100644 index 0000000000000..a4eb7e473d78e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/block-patterns.php @@ -0,0 +1,77 @@ +get_registered( $pattern_name ); + if ( $pattern ) { + unregister_block_pattern( $pattern_name ); + register_block_pattern( + $pattern_name, + $pattern + ); + } + } +} + +/** + * Return a function that loads and register block patterns from the API. This + * function can be registered to the `rest_dispatch_request` filter. + * + * @param Function $register_patterns_func A function that when called will + * register the relevant block patterns in the registry. + */ +function register_patterns_on_api_request( $register_patterns_func ) { + /** + * Load editing toolkit block patterns from the API. + * + * It will only register the patterns for certain allowed requests and + * return early otherwise. + * + * @param mixed $response + * @param WP_REST_Request $request + */ + return function ( $response, $request ) use ( $register_patterns_func ) { + /** + * Do nothing if it is loaded in the ETK. + */ + if ( class_exists( 'A8C\FSE\Block_Patterns_From_API' ) ) { + return $response; + } + + $route = $request->get_route(); + // Matches either /wp/v2/sites/123/block-patterns/patterns or /wp/v2/block-patterns/patterns + // to handle the API format of both WordPress.com and WordPress core. + $request_allowed = preg_match( '/^\/wp\/v2\/(sites\/[0-9]+\/)?block\-patterns\/(patterns|categories)$/', $route ); + + if ( ! $request_allowed || ! apply_filters( 'a8c_enable_block_patterns_api', false ) ) { + return $response; + } + + $register_patterns_func(); + + wpcom_reorder_curated_core_patterns(); + + return $response; + }; +} +add_filter( + 'rest_dispatch_request', + register_patterns_on_api_request( + function () { + require_once __DIR__ . '/class-wpcom-block-patterns-from-api.php'; + ( new Wpcom_Block_Patterns_From_Api() )->register_patterns(); + } + ), + 11, + 2 +); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php new file mode 100644 index 0000000000000..75410298a9467 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php @@ -0,0 +1,328 @@ +register_patterns() + * + * @var array + */ + private $core_to_wpcom_categories_dictionary; + + /** + * Block_Patterns constructor. + * + * @param Wpcom_Block_Patterns_Utils|null $utils A class dependency containing utils methods. + */ + public function __construct( Wpcom_Block_Patterns_Utils $utils = null ) { + $this->patterns_sources = array( 'block_patterns' ); + + $this->utils = empty( $utils ) ? new Wpcom_Block_Patterns_Utils() : $utils; + + // Add categories to this array using the core pattern name as the key for core patterns we wish to "recategorize". + $this->core_to_wpcom_categories_dictionary = array( + 'core/quote' => array( + 'quotes' => __( 'Quotes', 'jetpack-mu-wpcom' ), + 'text' => __( 'Text', 'jetpack-mu-wpcom' ), + ), + ); + } + + /** + * Register FSE block patterns and categories. + * + * @return array Results of pattern registration. + */ + public function register_patterns() { + // Used to track which patterns we successfully register. + $results = array(); + + // For every pattern source site, fetch the patterns. + foreach ( $this->patterns_sources as $patterns_source ) { + $patterns_cache_key = $this->utils->get_patterns_cache_key( $patterns_source ); + + $pattern_categories = array(); + $block_patterns = $this->get_patterns( $patterns_cache_key, $patterns_source ); + + foreach ( (array) $block_patterns as $pattern ) { + foreach ( (array) $pattern['categories'] as $slug => $category ) { + // Register categories from first pattern in each category. + if ( ! isset( $pattern_categories[ $slug ] ) ) { + $pattern_categories[ $slug ] = array( + 'label' => $category['title'], + 'description' => $category['description'], + ); + } + } + } + + // Unregister existing categories so that we can insert them in the desired order (alphabetically). + $existing_categories = array(); + foreach ( \WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered() as $existing_category ) { + $existing_categories[ $existing_category['name'] ] = $existing_category; + unregister_block_pattern_category( $existing_category['name'] ); + } + + // Existing categories are registered in Gutenberg or other plugins. + // We overwrite them with the categories from Dotcom patterns. + $pattern_categories = array_merge( $existing_categories, $pattern_categories ); + + // Order categories alphabetically by their label. + uasort( + $pattern_categories, + function ( $a, $b ) { + return strnatcasecmp( $a['label'], $b['label'] ); + } + ); + + // Move the Featured category to be the first category. + if ( isset( $pattern_categories['featured'] ) ) { + $featured_category = $pattern_categories['featured']; + $pattern_categories = array( 'featured' => $featured_category ) + $pattern_categories; + } + + // Register categories (and re-register existing categories). + foreach ( (array) $pattern_categories as $slug => &$category_properties ) { + // Rename category labels. + if ( 'posts' === $slug ) { + $category_properties['label'] = __( + 'Blog Posts', + 'jetpack-mu-wpcom' + ); + } elseif ( 'testimonials' === $slug ) { + $category_properties['label'] = __( + 'Quotes', + 'jetpack-mu-wpcom' + ); + } + register_block_pattern_category( $slug, $category_properties ); + } + + foreach ( (array) $block_patterns as &$pattern ) { + if ( $this->can_register_pattern( $pattern ) ) { + $is_premium = isset( $pattern['pattern_meta']['is_premium'] ) ? boolval( $pattern['pattern_meta']['is_premium'] ) : false; + + // Set custom viewport width for the pattern preview with a + // default width of 1280 and ensure a safe minimum width of 320. + $viewport_width = isset( $pattern['pattern_meta']['viewport_width'] ) ? intval( $pattern['pattern_meta']['viewport_width'] ) : 1280; + $viewport_width = $viewport_width < 320 ? 320 : $viewport_width; + $pattern_name = self::PATTERN_NAMESPACE . $pattern['name']; + $block_types = $this->utils->maybe_get_pattern_block_types_from_pattern_meta( $pattern ); + + $results[ $pattern_name ] = register_block_pattern( + $pattern_name, + array( + 'title' => $pattern['title'], + 'description' => $pattern['description'], + 'content' => $pattern['html'], + 'viewportWidth' => $viewport_width, + 'categories' => array_keys( + $pattern['categories'] + ), + 'isPremium' => $is_premium, + 'blockTypes' => $block_types, + ) + ); + } + } + } + + $this->update_core_patterns_with_wpcom_categories(); + $this->update_pattern_block_types(); + + // Temporarily removing the call to `update_pattern_post_types` while we investigate + // https://github.com/Automattic/wp-calypso/issues/79145. + + return $results; + } + + /** + * Returns a list of patterns. + * + * @param string $patterns_cache_key Key to store responses to and fetch responses from cache. + * @param string $patterns_source Slug for valid patterns source site, e.g., `block_patterns`. + * @return array The list of translated patterns. + */ + private function get_patterns( $patterns_cache_key, $patterns_source ) { + $override_source_site = apply_filters( 'a8c_override_patterns_source_site', false ); + if ( $override_source_site ) { + // Skip caching and request all patterns from a specified source site. + // This allows testing patterns in development with immediate feedback + // while avoiding polluting the cache. Note that this request gets + // all patterns on the source site, not just those with the 'pattern' tag. + $request_url = esc_url_raw( + add_query_arg( + array( + 'site' => $override_source_site, + 'tags' => 'pattern', + 'pattern_meta' => 'is_web', + ), + 'https://public-api.wordpress.com/rest/v1/ptk/patterns/' . $this->utils->get_block_patterns_locale() + ) + ); + + return $this->utils->remote_get( $request_url ); + } + + $block_patterns = $this->utils->cache_get( $patterns_cache_key, 'ptk_patterns' ); + + // Load fresh data if we don't have any patterns. + if ( false === $block_patterns || ( defined( 'WP_DISABLE_PATTERN_CACHE' ) && WP_DISABLE_PATTERN_CACHE ) ) { + $request_url = esc_url_raw( + add_query_arg( + array( + 'tags' => 'pattern', + 'pattern_meta' => 'is_web', + 'patterns_source' => $patterns_source, + ), + 'https://public-api.wordpress.com/rest/v1/ptk/patterns/' . $this->utils->get_block_patterns_locale() + ) + ); + + $block_patterns = $this->utils->remote_get( $request_url ); + + $this->utils->cache_add( $patterns_cache_key, $block_patterns, 'ptk_patterns', DAY_IN_SECONDS ); + } + + return $block_patterns; + } + + /** + * Check that the pattern is allowed to be registered. + * + * Checks for pattern_meta tags with a prefix of `requires-` in the name, and then attempts to match + * the remainder of the name to a theme feature. + * + * For example, to prevent patterns that depend on wide or full-width block alignment support + * from being registered in sites where the active theme does not have `align-wide` support, + * we can add the `requires-align-wide` pattern_meta tag to the pattern. This function will + * then match against that pattern_meta tag, and then return `false`. + * + * @param array $pattern A pattern with a 'pattern_meta' array where the key is the tag slug in English. + * + * @return bool + */ + private function can_register_pattern( $pattern ) { + if ( empty( $pattern['pattern_meta'] ) ) { + // Default to allowing patterns without metadata to be registered. + return true; + } + + foreach ( $pattern['pattern_meta'] as $pattern_meta => $value ) { + // Match against tags with a non-translated slug beginning with `requires-`. + $split_slug = preg_split( '/^requires-/', $pattern_meta ); + + // If the theme does not support the matched feature, then skip registering the pattern. + if ( isset( $split_slug[1] ) && false === get_theme_support( $split_slug[1] ) ) { + return false; + } + } + + return true; + } + + /** + * Update categories for core patterns if a records exists in $this->core_to_wpcom_categories_dictionary + * and re-registers them. + */ + private function update_core_patterns_with_wpcom_categories() { + if ( class_exists( 'WP_Block_Patterns_Registry' ) ) { + foreach ( \WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) { + $wpcom_categories = + $pattern['name'] && isset( $this->core_to_wpcom_categories_dictionary[ $pattern['name'] ] ) + ? $this->core_to_wpcom_categories_dictionary[ $pattern['name'] ] + : null; + if ( $wpcom_categories ) { + unregister_block_pattern( $pattern['name'] ); + $pattern_properties = array_merge( + $pattern, + array( 'categories' => array_keys( $wpcom_categories ) ) + ); + unset( $pattern_properties['name'] ); + register_block_pattern( + $pattern['name'], + $pattern_properties + ); + } + } + } + } + + /** + * Ensure that all patterns with a blockType property are registered with appropriate postTypes. + */ + private function update_pattern_post_types() { + if ( ! class_exists( 'WP_Block_Patterns_Registry' ) ) { + return; + } + foreach ( \WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) { + if ( array_key_exists( 'postTypes', $pattern ) && $pattern['postTypes'] ) { + continue; + } + + $post_types = $this->utils->get_pattern_post_types_from_pattern( $pattern ); + if ( $post_types ) { + unregister_block_pattern( $pattern['name'] ); + + $pattern['postTypes'] = $post_types; + $pattern_name = $pattern['name']; + unset( $pattern['name'] ); + register_block_pattern( $pattern_name, $pattern ); + } + } + } + + /** + * Ensure that all patterns with a blockType property are registered with appropriate postTypes. + */ + private function update_pattern_block_types() { + if ( ! class_exists( 'WP_Block_Patterns_Registry' ) ) { + return; + } + foreach ( \WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) { + if ( ! array_key_exists( 'blockTypes', $pattern ) || empty( $pattern['blockTypes'] ) ) { + continue; + } + + $post_content_offset = array_search( 'core/post-content', $pattern['blockTypes'], true ); + if ( $post_content_offset !== false ) { + unregister_block_pattern( $pattern['name'] ); + + $pattern['blockTypes'] = array_splice( $pattern['blockTypes'], $post_content_offset, 1 ); + $pattern_name = $pattern['name']; + unset( $pattern['name'] ); + register_block_pattern( $pattern_name, $pattern ); + } + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-utils.php b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-utils.php new file mode 100644 index 0000000000000..9e390f09159c6 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-utils.php @@ -0,0 +1,151 @@ + 20 ); + + if ( function_exists( 'wpcom_json_api_get' ) ) { + $response = wpcom_json_api_get( $request_url, $args ); + } else { + $response = wp_remote_get( $request_url, $args ); + } + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + return array(); + } + return json_decode( wp_remote_retrieve_body( $response ), true ); + } + + /** + * A wrapper for wp_cache_add. + * + * @param int|string $key The cache key to use for retrieval later. + * @param mixed $data The data to add to the cache. + * @param string $group The group to add the cache to. Enables the same key to be used across groups. Default empty. + * @param int $expire When the cache data should expire, in seconds. + * Default 0 (no expiration). + * @return bool True on success, false if cache key and group already exist. + */ + public function cache_add( $key, $data, $group, $expire ) { + return wp_cache_add( $key, $data, $group, $expire ); + } + + /** + * A wrapper for wp_cache_get. + * + * @param int|string $key The key under which the cache contents are stored. + * @param string $group Where the cache contents are grouped. Default empty. + * @return mixed|false The cache contents on success, false on failure to retrieve contents. + */ + public function cache_get( $key, $group ) { + return wp_cache_get( $key, $group ); + } + + /** + * Returns the sha1 hash of a concatenated string to use as a cache key. + * + * @param string $patterns_slug A slug for a patterns source site, e.g., `block_patterns`. + * @return string locale slug + */ + public function get_patterns_cache_key( $patterns_slug ) { + return sha1( + implode( + '_', + array( + $patterns_slug, + Jetpack_Mu_Wpcom::PACKAGE_VERSION, + $this->get_block_patterns_locale(), + ) + ) + ); + } + + /** + * Get the locale to be used for fetching block patterns + * + * @return string locale slug + */ + public function get_block_patterns_locale() { + // Block patterns display in the user locale. + $language = get_user_locale(); + return Common\get_iso_639_locale( $language ); + } + + /** + * Check for block type values in the pattern_meta tag. + * When tags have a prefix of `block_type_`, we expect the remaining suffix to be a blockType value. + * We'll add these values to the `(array) blockType` options property when registering the pattern + * via `register_block_pattern`. + * + * @param array $pattern A pattern with a 'pattern_meta' array. + * + * @return array An array of block types defined in pattern meta. + */ + public function maybe_get_pattern_block_types_from_pattern_meta( $pattern ) { + $block_types = array(); + + if ( ! isset( $pattern['pattern_meta'] ) || empty( $pattern['pattern_meta'] ) ) { + return $block_types; + } + + foreach ( $pattern['pattern_meta'] as $pattern_meta => $value ) { + // Match against tags starting with `block_type_`. + $split_slug = preg_split( '/^block_type_/', $pattern_meta ); + + if ( isset( $split_slug[1] ) ) { + $block_types[] = $split_slug[1]; + } + } + + return $block_types; + } + + /** + * Return pattern post types based on the pattern's blockTypes. + * + * @param array $pattern A pattern array such as is passed to `register_block_pattern`. + * + * @return array $postTypes An array of post type names such as is passed to `register_block_pattern`. + */ + public function get_pattern_post_types_from_pattern( $pattern ) { + $post_types = array_key_exists( 'postTypes', $pattern ) ? $pattern['postTypes'] : array(); + if ( $post_types ) { + // If some postTypes are explicitly set then respect the pattern author's intent. + return $post_types; + } + + $block_types = array_key_exists( 'blockTypes', $pattern ) ? $pattern['blockTypes'] : array(); + $block_types_count = count( $block_types ); + $template_parts = array_filter( + $block_types, + function ( $block_type ) { + return preg_match( '#core/template-part/#', $block_type ); + } + ); + // If all of a patterns blockTypes are template-parts then limit the postTypes to just + // the template related types and to pages - this is to avoid the pattern appearing in + // the inserter for posts and other post types. Pages are included because it's not unusual + // to use a blank template and add a specific header and footer to a page. + if ( $block_types_count && count( $template_parts ) === $block_types_count ) { + $post_types = array( 'wp_template', 'wp_template_part', 'page' ); + } + return $post_types; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/block-patterns/class-wpcom-block-patterns-from-api-test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/block-patterns/class-wpcom-block-patterns-from-api-test.php new file mode 100644 index 0000000000000..e3e401a06877e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/block-patterns/class-wpcom-block-patterns-from-api-test.php @@ -0,0 +1,228 @@ +pattern_mock_object = array( + 'ID' => '1', + 'site_id' => '2', + 'title' => 'test title', + 'name' => 'test pattern name', + 'description' => 'test description', + 'html' => '

test

', + 'source_url' => 'http;//test', + 'modified_date' => 'dd:mm:YY', + 'categories' => array( + 'test_slug' => array( + 'title' => 'category title', + 'description' => 'category description', + ), + ), + ); + } + + /** + * Returns a mock of Wpcom_Block_Patterns_Utils. + * + * @param array $pattern_mock_response What we want Wpcom_Block_Patterns_Utils->remote_get() to return. + * @param bool|array $cache_get What we want Wpcom_Block_Patterns_Utils->cache_get() to return. + * @param bool $cache_add What we want Wpcom_Block_Patterns_Utils->cache_add() to return. + * @param string $get_patterns_cache_key What we want Wpcom_Block_Patterns_Utils->get_patterns_cache_key() to return. + * @param string $get_block_patterns_locale What we want Wpcom_Block_Patterns_Utils->get_block_patterns_locale() to return. + * @return obj PHP Unit mock object. + */ + public function createBlockPatternsUtilsMock( $pattern_mock_response, $cache_get = false, $cache_add = true, $get_patterns_cache_key = 'key-largo', $get_block_patterns_locale = 'fr' ) { + $mock = $this->createMock( Wpcom_Block_Patterns_Utils::class ); + + $mock->method( 'remote_get' ) + ->willReturn( $pattern_mock_response ); + + $mock->method( 'cache_get' ) + ->willReturn( $cache_get ); + + $mock->method( 'cache_add' ) + ->willReturn( $cache_add ); + + $mock->method( 'get_patterns_cache_key' ) + ->willReturn( $get_patterns_cache_key ); + + $mock->method( 'get_block_patterns_locale' ) + ->willReturn( $get_block_patterns_locale ); + + return $mock; + } + + /** + * Tests that we're making a request where there are no cached patterns. + */ + public function test_patterns_request_succeeds_with_empty_cache() { + $utils_mock = $this->createBlockPatternsUtilsMock( array( $this->pattern_mock_object ) ); + $block_patterns_from_api = new Wpcom_Block_Patterns_From_Api( $utils_mock ); + + $utils_mock->expects( $this->once() ) + ->method( 'cache_get' ) + ->willReturn( false ); + + $utils_mock->expects( $this->once() ) + ->method( 'remote_get' ) + ->with( 'https://public-api.wordpress.com/rest/v1/ptk/patterns/fr?tags=pattern&pattern_meta=is_web&patterns_source=block_patterns' ); + + $utils_mock->expects( $this->once() ) + ->method( 'cache_add' ) + ->with( $this->stringContains( 'key-largo' ), array( $this->pattern_mock_object ), 'ptk_patterns', DAY_IN_SECONDS ); + + $this->assertEquals( array( 'a8c/' . $this->pattern_mock_object['name'] => true ), $block_patterns_from_api->register_patterns() ); + } + + /** + * Tests that we're making a request + */ + public function test_patterns_site_editor_source_site() { + $utils_mock = $this->createBlockPatternsUtilsMock( array( $this->pattern_mock_object ) ); + $block_patterns_from_api = new Wpcom_Block_Patterns_From_Api( $utils_mock ); + + $utils_mock->expects( $this->exactly( 1 ) ) + ->method( 'remote_get' ) + ->withConsecutive( + array( 'https://public-api.wordpress.com/rest/v1/ptk/patterns/fr?tags=pattern&pattern_meta=is_web&patterns_source=block_patterns' ) + ); + + $this->assertEquals( array( 'a8c/' . $this->pattern_mock_object['name'] => true ), $block_patterns_from_api->register_patterns() ); + } + + /** + * Tests that we're NOT making a request where there ARE cached patterns. + */ + public function test_patterns_request_succeeds_with_set_cache() { + $utils_mock = $this->createBlockPatternsUtilsMock( array( $this->pattern_mock_object ), array( $this->pattern_mock_object ) ); + $block_patterns_from_api = new Wpcom_Block_Patterns_From_Api( $utils_mock ); + + $utils_mock->expects( $this->once() ) + ->method( 'cache_get' ) + ->with( $this->stringContains( 'key-largo' ), 'ptk_patterns' ); + + $utils_mock->expects( $this->never() ) + ->method( 'remote_get' ); + + $utils_mock->expects( $this->never() ) + ->method( 'cache_add' ); + + $this->assertEquals( array( 'a8c/' . $this->pattern_mock_object['name'] => true ), $block_patterns_from_api->register_patterns() ); + } + + /** + * Tests that we're making a request where we're overriding the source site. + */ + public function test_patterns_request_succeeds_with_override_source_site() { + $example_site = function () { + return 'dotcom'; + }; + + add_filter( 'a8c_override_patterns_source_site', $example_site ); + $utils_mock = $this->createBlockPatternsUtilsMock( array( $this->pattern_mock_object ) ); + $block_patterns_from_api = new Wpcom_Block_Patterns_From_Api( $utils_mock ); + + $utils_mock->expects( $this->never() ) + ->method( 'cache_get' ); + + $utils_mock->expects( $this->never() ) + ->method( 'cache_add' ); + + $utils_mock->expects( $this->once() ) + ->method( 'remote_get' ) + ->with( 'https://public-api.wordpress.com/rest/v1/ptk/patterns/fr?site=dotcom&tags=pattern&pattern_meta=is_web' ); + + $this->assertEquals( array( 'a8c/' . $this->pattern_mock_object['name'] => true ), $block_patterns_from_api->register_patterns() ); + + remove_filter( 'a8c_override_patterns_source_site', $example_site ); + } + + /** + * Tests the given patterns registration mock against multiple REST API routes. + * + * @param object $patterns_from_api_mock A mock object for the block pattern from API class. + * @param array $test_routes An array of strings of routes to test. + */ + public function multiple_route_pattern_registration( $patterns_from_api_mock, $test_routes ) { + foreach ( $test_routes as $route ) { + $request_mock = $this->createMock( \WP_REST_Request::class ); + $request_mock->method( 'get_route' )->willReturn( $route ); + + $function = register_patterns_on_api_request( + function () use ( $patterns_from_api_mock ) { + $patterns_from_api_mock->register_patterns(); + } + ); + $function( null, $request_mock ); + } + } + + /** + * Tests that pattern registration does occur on API routes related to block patterns. + */ + public function test_load_Wpcom_Block_Patterns_From_Api_runs_in_correct_request_context() { + add_filter( 'a8c_enable_block_patterns_api', '__return_true' ); + $test_routes = array( + '/wp/v2/block-patterns/categories', + '/wp/v2/block-patterns/patterns', + '/wp/v2/sites/178915379/block-patterns/categories', + '/wp/v2/sites/178915379/block-patterns/patterns', + ); + + $patterns_mock = $this->createMock( Wpcom_Block_Patterns_From_Api::class ); + $patterns_mock->expects( $this->exactly( count( $test_routes ) ) )->method( 'register_patterns' ); + + $this->multiple_route_pattern_registration( $patterns_mock, $test_routes ); + } + + /** + * Tests that pattern registration does not occur on rest API routes unrelated + * to block patterns. + */ + public function test_load_Wpcom_Block_Patterns_From_Api_is_skipped_in_wrong_request_context() { + add_filter( 'a8c_enable_block_patterns_api', '__return_true' ); + + $test_routes = array( + '/rest/v1.1/help/olark/mine', + '/wpcom/v2/sites/178915379/post-counts', + '/rest/v1.1/me/shopping-cart/', + '/wpcom/v3/sites/178915379/gutenberg', + '/wp/v2/sites/178915379/types', + '/wp/v2/sites/178915379/block-patterns/3ategories', + '/wp/v2//block-patterns/patterns', + '/wp/v2block-patterns/categories', + '/wp/v2/123/block-patterns/categories', + ); + + $patterns_mock = $this->createMock( Wpcom_Block_Patterns_From_Api::class ); + $patterns_mock->expects( $this->never() )->method( 'register_patterns' ); + + $this->multiple_route_pattern_registration( $patterns_mock, $test_routes ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/block-patterns/class-wpcom-block-patterns-utils-test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/block-patterns/class-wpcom-block-patterns-utils-test.php new file mode 100644 index 0000000000000..10903c6d9debc --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/block-patterns/class-wpcom-block-patterns-utils-test.php @@ -0,0 +1,122 @@ +utils = new Wpcom_Block_Patterns_Utils(); + } + + /** + * Tests that we receive an empty block_types array where there are no block types in pattern_meta + */ + public function test_should_return_empty_array_from_block_types_check() { + $test_pattern = $this->get_test_pattern(); + $block_types = $this->utils->maybe_get_pattern_block_types_from_pattern_meta( $test_pattern ); + + $this->assertEmpty( $block_types ); + } + + /** + * Tests that we can parse block types from pattern_meta. + */ + public function test_should_return_block_types_from_patterns_meta() { + $test_pattern = $this->get_test_pattern( + array( + 'pattern_meta' => array( + 'block_type_core/template-part/footer' => true, + ), + ) + ); + $block_types = $this->utils->maybe_get_pattern_block_types_from_pattern_meta( $test_pattern ); + + $this->assertEquals( array( 'core/template-part/footer' ), $block_types ); + } + + /** + * Tests that template-based post types are generated from block types + */ + public function test_should_return_post_types_from_pattern() { + $pattern = array( 'blockTypes' => array( 'core/template-part/header' ) ); + + $post_types = $this->utils->get_pattern_post_types_from_pattern( $pattern ); + + $this->assertEquals( array( 'wp_template', 'wp_template_part', 'page' ), $post_types ); + } + + /** + * Tests that template-based post types are not generated without block types + */ + public function test_should_return_empty_array_if_nothing_to_parse() { + $post_types = $this->utils->get_pattern_post_types_from_pattern( array() ); + + $this->assertEquals( array(), $post_types ); + } + + /** + * Tests that existing postTypes are preserved without modification + */ + public function test_should_not_modify_existing_post_types() { + $pattern = array( + 'blockTypes' => array( 'core/template-part/header' ), + 'postTypes' => array( 'post' ), + ); + + $post_types = $this->utils->get_pattern_post_types_from_pattern( $pattern ); + + $this->assertEquals( array( 'post' ), $post_types ); + } + + /** + * Util function from grabbing a test pattern. + * + * @param array $new_pattern_values Values to merge into the default array. + * @return array A test pattern. + */ + private function get_test_pattern( $new_pattern_values = array() ) { + $default_pattern = array( + 'ID' => '1', + 'site_id' => '2', + 'title' => 'test title', + 'name' => 'test pattern name', + 'description' => 'test description', + 'html' => '

test

', + 'source_url' => 'http;//test', + 'modified_date' => 'dd:mm:YY', + 'categories' => array( + array( + 'title' => 'test-category', + ), + ), + 'pattern_meta' => array( + 'is_web' => true, + ), + ); + + return array_merge( $default_pattern, $new_pattern_values ); + } +} diff --git a/projects/packages/stats-admin/changelog/fix-only-load-stats-data-when-widget-is-visible b/projects/packages/stats-admin/changelog/fix-only-load-stats-data-when-widget-is-visible new file mode 100644 index 0000000000000..0bd21fef5c1bf --- /dev/null +++ b/projects/packages/stats-admin/changelog/fix-only-load-stats-data-when-widget-is-visible @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Support load scripts conditionally for Stats widget diff --git a/projects/packages/stats-admin/composer.json b/projects/packages/stats-admin/composer.json index de7edf7c2c607..a00955ae0cac2 100644 --- a/projects/packages/stats-admin/composer.json +++ b/projects/packages/stats-admin/composer.json @@ -52,7 +52,7 @@ "autotagger": true, "mirror-repo": "Automattic/jetpack-stats-admin", "branch-alias": { - "dev-trunk": "0.13.x-dev" + "dev-trunk": "0.14.x-dev" }, "textdomain": "jetpack-stats-admin", "version-constants": { diff --git a/projects/packages/stats-admin/package.json b/projects/packages/stats-admin/package.json index dc7a3691ce4bb..62068ae6a96a0 100644 --- a/projects/packages/stats-admin/package.json +++ b/projects/packages/stats-admin/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-stats-admin", - "version": "0.13.0", + "version": "0.14.0-alpha", "description": "Stats Dashboard", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/stats-admin/#readme", "bugs": { diff --git a/projects/packages/stats-admin/src/class-main.php b/projects/packages/stats-admin/src/class-main.php index 77d61d6a1ec39..c5a315c5ef91e 100644 --- a/projects/packages/stats-admin/src/class-main.php +++ b/projects/packages/stats-admin/src/class-main.php @@ -22,7 +22,7 @@ class Main { /** * Stats version. */ - const VERSION = '0.13.0'; + const VERSION = '0.14.0-alpha'; /** * Singleton Main instance. diff --git a/projects/packages/stats-admin/src/class-wp-dashboard-odyssey-widget.php b/projects/packages/stats-admin/src/class-wp-dashboard-odyssey-widget.php index e831368ff8eae..9be1643e81c29 100644 --- a/projects/packages/stats-admin/src/class-wp-dashboard-odyssey-widget.php +++ b/projects/packages/stats-admin/src/class-wp-dashboard-odyssey-widget.php @@ -13,11 +13,14 @@ * @package jetpack-stats-admin */ class WP_Dashboard_Odyssey_Widget { + const DASHBOARD_WIDGET_ID = 'jetpack_summary_widget'; /** * Renders the widget and fires a dashboard widget action. */ - public static function render() { + public function render() { + // The widget is always rendered, so if it was hidden and then toggled open, we need to ask user to refresh the page to load data properly. + $is_toggled_open = $this->is_widget_hidden(); ?>
@@ -30,6 +33,13 @@ class="jp-stats-widget-loading-spinner" alt= src="//en.wordpress.com/i/loading/loading-64.gif" /> +

+ +

is_widget_hidden() ) { + return; + } + $this->load_admin_scripts(); + } + + /** + * Returns true if the widget is hidden for the current screen and current user. + * + * @return bool + */ + public function is_widget_hidden() { + $hidden = get_hidden_meta_boxes( get_current_screen() ); + return in_array( self::DASHBOARD_WIDGET_ID, $hidden, true ); + } } diff --git a/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php b/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php index b782215aa1dca..401478b4254e7 100644 --- a/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php +++ b/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php @@ -774,6 +774,16 @@ public static function register_endpoints() { 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::set_subscriber_cookie_and_redirect', 'permission_callback' => '__return_true', + 'args' => array( + 'redirect_url' => array( + 'required' => true, + 'description' => __( 'The URL to redirect_to.', 'jetpack' ), + 'validate_callback' => 'wp_http_validate_url', + 'sanitize_callback' => 'sanitize_url', + 'type' => 'string', + 'format' => 'uri', + ), + ), ) ); } @@ -815,16 +825,18 @@ public static function get_openai_jwt() { /** * Set subscriber cookie and redirect * + * @param \WP_Rest_Request $request The URL to redirect to. + * * @return WP_Error|WP_REST_Response */ - public static function set_subscriber_cookie_and_redirect() { + public static function set_subscriber_cookie_and_redirect( $request ) { require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php'; $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service(); $token = $subscription_service->get_and_set_token_from_request(); $payload = $subscription_service->decode_token( $token ); $is_valid_token = ! empty( $payload ); - if ( $is_valid_token && isset( $payload['redirect_url'] ) ) { - return new WP_REST_Response( null, 302, array( 'location' => $payload['redirect_url'] ) ); + if ( $is_valid_token ) { + return new WP_REST_Response( null, 302, array( 'location' => $request['redirect_url'] ) ); } return new WP_Error( 'invalid-token', 'Invalid Token' ); } diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-newsletter-categories-subscriptions-count.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-newsletter-categories-subscriptions-count.php new file mode 100644 index 0000000000000..64fe8599b280b --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-newsletter-categories-subscriptions-count.php @@ -0,0 +1,89 @@ +wpcom_is_wpcom_only_endpoint = true; + $this->wpcom_is_site_specific_endpoint = true; + $this->base_api_path = 'wpcom'; + $this->version = 'v2'; + $this->namespace = $this->base_api_path . '/' . $this->version; + $this->rest_base = '/newsletter-categories/count'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Register routes. + */ + public function register_routes() { + $options = array( + 'show_in_index' => true, + 'methods' => 'GET', + // if this is not a wpcom site, we need to proxy the request to wpcom + 'callback' => ( ( new Host() )->is_wpcom_simple() ) ? array( + $this, + 'get_newsletter_categories_subscriptions_count', + ) : array( $this, 'proxy_request_to_wpcom_as_user' ), + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => array( + 'term_ids' => array( + 'required' => false, + 'validate_callback' => function ( $param ) { + return empty( $param ) || ( is_string( $param ) && preg_match( '/^(\d+,)*\d+$/', $param ) ); + }, + 'default' => '', + ), + ), + ); + + register_rest_route( + $this->namespace, + $this->rest_base, + $options + ); + } + + /** + * Get the subscriptions count for the given categories. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response + */ + public function get_newsletter_categories_subscriptions_count( WP_REST_Request $request ) { + require_lib( 'newsletter-categories' ); + + $blog_id = get_current_blog_id(); + $term_ids = explode( ',', $request->get_param( 'term_ids' ) ); + + $subscriptions_count = get_blog_subscriptions_aggregate_count( $blog_id, $term_ids ); + + return rest_ensure_response( + array( + 'subscriptions_count' => $subscriptions_count, + ) + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Newsletter_Categories_Subscriptions_Count' ); diff --git a/projects/plugins/jetpack/changelog/add-prevent-batcache-when-accessing-paywalled-content b/projects/plugins/jetpack/changelog/add-prevent-batcache-when-accessing-paywalled-content new file mode 100644 index 0000000000000..705c91e52c301 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-prevent-batcache-when-accessing-paywalled-content @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Prevent caching paywalled content diff --git a/projects/plugins/jetpack/changelog/add-remove-unecessary-v2 b/projects/plugins/jetpack/changelog/add-remove-unecessary-v2 new file mode 100644 index 0000000000000..1ef8afea050a2 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-remove-unecessary-v2 @@ -0,0 +1,4 @@ +Significance: patch +Type: other + + diff --git a/projects/plugins/jetpack/changelog/add-subscribers-count b/projects/plugins/jetpack/changelog/add-subscribers-count new file mode 100644 index 0000000000000..a43209425be12 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-subscribers-count @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Display subscribers count on pre/post publish panels diff --git a/projects/plugins/jetpack/changelog/fix-only-load-stats-data-when-widget-is-visible b/projects/plugins/jetpack/changelog/fix-only-load-stats-data-when-widget-is-visible new file mode 100644 index 0000000000000..0ee803922a830 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-only-load-stats-data-when-widget-is-visible @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Odyssey Stats widget: only load scripts when the widget is visible diff --git a/projects/plugins/jetpack/changelog/fix-only-load-stats-data-when-widget-is-visible#2 b/projects/plugins/jetpack/changelog/fix-only-load-stats-data-when-widget-is-visible#2 new file mode 100644 index 0000000000000..a1c1831fa1ef7 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-only-load-stats-data-when-widget-is-visible#2 @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Updated composer.lock. + + diff --git a/projects/plugins/jetpack/changelog/fix-paywall-block-registration b/projects/plugins/jetpack/changelog/fix-paywall-block-registration new file mode 100644 index 0000000000000..2d052d503c7e7 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-paywall-block-registration @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Fix block name usage when unregistering Paywall block diff --git a/projects/plugins/jetpack/class-jetpack-stats-dashboard-widget.php b/projects/plugins/jetpack/class-jetpack-stats-dashboard-widget.php index 2336134b3885c..614557863a3de 100644 --- a/projects/plugins/jetpack/class-jetpack-stats-dashboard-widget.php +++ b/projects/plugins/jetpack/class-jetpack-stats-dashboard-widget.php @@ -68,15 +68,16 @@ public static function wp_dashboard_setup() { // New widget implemented in Odyssey Stats. $stats_widget = new Dashboard_Stats_Widget(); wp_add_dashboard_widget( - 'jetpack_summary_widget', + Dashboard_Stats_Widget::DASHBOARD_WIDGET_ID, $widget_title, array( $stats_widget, 'render' ) ); - $stats_widget->load_admin_scripts(); + // Only load scripts when the widget is not hidden + $stats_widget->maybe_load_admin_scripts(); } else { // Legacy widget. wp_add_dashboard_widget( - 'jetpack_summary_widget', + Dashboard_Stats_Widget::DASHBOARD_WIDGET_ID, $widget_title, array( __CLASS__, 'render_widget' ) ); diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index b15e6b277c0f4..7baafc1e93c91 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -2338,7 +2338,7 @@ "dist": { "type": "path", "url": "../../packages/stats-admin", - "reference": "3f110b7bd2d893fcea2d2d34d96d9d525cf69004" + "reference": "86416376823afc0d70cb40dd35c9d6a8d60c241b" }, "require": { "automattic/jetpack-connection": "@dev", @@ -2362,7 +2362,7 @@ "autotagger": true, "mirror-repo": "Automattic/jetpack-stats-admin", "branch-alias": { - "dev-trunk": "0.13.x-dev" + "dev-trunk": "0.14.x-dev" }, "textdomain": "jetpack-stats-admin", "version-constants": { diff --git a/projects/plugins/jetpack/extensions/blocks/paywall/editor.js b/projects/plugins/jetpack/extensions/blocks/paywall/editor.js index 84a45c2e5545d..2fed26ac672f3 100644 --- a/projects/plugins/jetpack/extensions/blocks/paywall/editor.js +++ b/projects/plugins/jetpack/extensions/blocks/paywall/editor.js @@ -18,7 +18,7 @@ const unsubscribe = subscribe( () => { unsubscribe(); // If postType is defined and not 'post', unregister the block. if ( postType && postType !== 'post' ) { - unregisterBlockType( 'jetpack/' + name ); + unregisterBlockType( metadata.name ); } } ); diff --git a/projects/plugins/jetpack/extensions/blocks/subscriptions/panel.js b/projects/plugins/jetpack/extensions/blocks/subscriptions/panel.js index ddf4d7428a729..4a8a5edf09972 100644 --- a/projects/plugins/jetpack/extensions/blocks/subscriptions/panel.js +++ b/projects/plugins/jetpack/extensions/blocks/subscriptions/panel.js @@ -28,6 +28,7 @@ import { } from '../../shared/memberships/settings'; import { getFormattedCategories, + getFormattedSubscriptionsCount, getShowMisconfigurationWarning, } from '../../shared/memberships/utils'; import { store as membershipProductsStore } from '../../store/membership-products'; @@ -208,6 +209,12 @@ function NewsletterPostPublishSettingsPanel( { accessLevel } ) { select( editorStore ).getEditedPostAttribute( 'categories' ) ); + const subscriptionsCount = useSelect( select => { + return select( 'jetpack/membership-products' ).getNewsletterCategoriesSubscriptionsCount( + postCategories + ); + } ); + const reachCount = getReachForAccessLevelKey( accessLevel, emailSubscribers, @@ -236,16 +243,18 @@ function NewsletterPostPublishSettingsPanel( { accessLevel } ) { accessLevel !== accessOptions.paid_subscribers.key ) { const formattedCategoryNames = getFormattedCategories( postCategories, newsletterCategories ); + const formattedSubscriptionsCount = getFormattedSubscriptionsCount( subscriptionsCount ); + const categoryNamesAndSubscriptionsCount = formattedCategoryNames + formattedSubscriptionsCount; if ( formattedCategoryNames ) { numberOfSubscribersText = sprintf( - // translators: %1s is the post name, %2s is the list of categories + // translators: %1s is the post name, %2s is the list of categories with subscriptions count __( '%1$s was sent to everyone subscribed to %2$s.', 'jetpack' ), postName, - formattedCategoryNames + categoryNamesAndSubscriptionsCount ); } } diff --git a/projects/plugins/jetpack/extensions/blocks/subscriptions/subscriptions.php b/projects/plugins/jetpack/extensions/blocks/subscriptions/subscriptions.php index ebd0fe0a44fce..10ce2b05e1ccf 100644 --- a/projects/plugins/jetpack/extensions/blocks/subscriptions/subscriptions.php +++ b/projects/plugins/jetpack/extensions/blocks/subscriptions/subscriptions.php @@ -993,11 +993,20 @@ function get_paywall_blocks( $newsletter_access_level ) { $sign_in = ''; $switch_accounts = ''; - $sign_in_link = add_query_arg( + + if ( ( new Host() )->is_wpcom_simple() ) { + // On WPCOM we will redirect directly to the current page + $redirect_url = get_current_url(); + } else { + // On self-hosted we will save and hide the token + $redirect_url = get_site_url() . '/wp-json/jetpack/v4/subscribers/auth'; + $redirect_url = add_query_arg( 'redirect_url', get_current_url(), $redirect_url ); + } + + $sign_in_link = add_query_arg( array( 'site_id' => intval( \Jetpack_Options::get_option( 'id' ) ), - 'redirect_url' => rawurlencode( get_current_url() ), - 'v2' => '', + 'redirect_url' => rawurlencode( $redirect_url ), ), 'https://subscribe.wordpress.com/memberships/jwt' ); diff --git a/projects/plugins/jetpack/extensions/shared/memberships/settings.js b/projects/plugins/jetpack/extensions/shared/memberships/settings.js index ee759b20ee3d3..a8cda56bdda80 100644 --- a/projects/plugins/jetpack/extensions/shared/memberships/settings.js +++ b/projects/plugins/jetpack/extensions/shared/memberships/settings.js @@ -27,6 +27,7 @@ import { } from './constants'; import { getFormattedCategories, + getFormattedSubscriptionsCount, getShowMisconfigurationWarning, MisconfigurationWarning, } from './utils'; @@ -310,6 +311,12 @@ export function NewsletterAccessPrePublishSettings( { accessLevel } ) { select( editorStore ).getEditedPostAttribute( 'categories' ) ); + const subscriptionsCount = useSelect( select => { + return select( 'jetpack/membership-products' ).getNewsletterCategoriesSubscriptionsCount( + postCategories + ); + } ); + if ( isLoading ) { return ( @@ -327,13 +334,16 @@ export function NewsletterAccessPrePublishSettings( { accessLevel } ) { } if ( newsletterCategoriesEnabled && newsletterCategories.length ) { const formattedCategoryNames = getFormattedCategories( postCategories, newsletterCategories ); + const formattedSubscriptionsCount = getFormattedSubscriptionsCount( subscriptionsCount ); + const categoryNamesAndSubscriptionsCount = + formattedCategoryNames + formattedSubscriptionsCount; if ( formattedCategoryNames ) { return createInterpolateElement( sprintf( - // translators: %1$s: list of categories names + // translators: %1$s is the list of categories with subscriptions count __( 'This post will be sent to everyone subscribed to %1$s.', 'jetpack' ), - formattedCategoryNames + categoryNamesAndSubscriptionsCount ), { strong: } ); diff --git a/projects/plugins/jetpack/extensions/shared/memberships/utils.js b/projects/plugins/jetpack/extensions/shared/memberships/utils.js index a0f7acb3fd0d4..7ed941f703fd7 100644 --- a/projects/plugins/jetpack/extensions/shared/memberships/utils.js +++ b/projects/plugins/jetpack/extensions/shared/memberships/utils.js @@ -124,3 +124,15 @@ export const getFormattedCategories = ( postCategories, newsletterCategories ) = return formattedCategories; }; + +export const getFormattedSubscriptionsCount = subscriptionsCount => { + if ( subscriptionsCount === 1 ) { + return __( ' (1 subscriber)', 'jetpack' ); + } + + return sprintf( + // translators: %s is the number of subscribers in numerical format + __( ' (%s subscribers)', 'jetpack' ), + subscriptionsCount + ); +}; diff --git a/projects/plugins/jetpack/extensions/store/membership-products/actions.js b/projects/plugins/jetpack/extensions/store/membership-products/actions.js index 5fb9a82eefc2d..4b5cfe5168c18 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/actions.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/actions.js @@ -113,3 +113,9 @@ export const setNewsletterCategories = newsletterCategories => ( { type: 'SET_NEWSLETTER_CATEGORIES', newsletterCategories, } ); + +export const setNewsletterCategoriesSubscriptionsCount = + newsletterCategoriesSubscriptionsCount => ( { + type: 'SET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT', + newsletterCategoriesSubscriptionsCount, + } ); diff --git a/projects/plugins/jetpack/extensions/store/membership-products/reducer.js b/projects/plugins/jetpack/extensions/store/membership-products/reducer.js index ed65d373ff2ff..840f84e5e0a45 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/reducer.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/reducer.js @@ -42,6 +42,11 @@ export default function reducer( state = DEFAULT_STATE, action ) { ...state, newsletterCategories: action.newsletterCategories, }; + case 'SET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT': + return { + ...state, + newsletterCategoriesSubscriptionsCount: action.newsletterCategoriesSubscriptionsCount, + }; } return state; } diff --git a/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js b/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js index a93fcd7169911..d542059570c1e 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js @@ -14,6 +14,7 @@ import { setConnectedAccountDefaultCurrency, setSubscriberCounts, setNewsletterCategories, + setNewsletterCategoriesSubscriptionsCount, } from './actions'; import { API_STATE_CONNECTED, API_STATE_NOTCONNECTED } from './constants'; import { onError } from './utils'; @@ -22,6 +23,8 @@ const EXECUTION_KEY = 'membership-products-resolver-getProducts'; const SUBSCRIBER_COUNT_EXECUTION_KEY = 'membership-products-resolver-getSubscriberCounts'; const GET_NEWSLETTER_CATEGORIES_EXECUTION_KEY = 'membership-products-resolver-getNewsletterCategories'; +const GET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT_EXECUTION_KEY = + 'membership-products-resolver-getNewsletterCategoriesSubscriptionsCount'; let hydratedFromAPI = false; const fetchMemberships = async () => { @@ -116,6 +119,32 @@ const fetchNewsletterCategories = async () => { return response; }; +export const fetchNewsletterCategoriesSubscriptionsCount = async termIds => { + const response = await apiFetch( { + path: `/wpcom/v2/newsletter-categories/count?term_ids=${ termIds.join( ',' ) }`, + method: 'GET', + } ); + + if ( ! response || typeof response !== 'object' ) { + throw new Error( 'Unexpected API response' ); + } + + /** + * WP_Error returns a list of errors with custom names: + * `errors: { foo: [ 'message' ], bar: [ 'message' ] }` + * Since we don't know their names, to get the message, we transform the object + * into an array, and just pick the first message of the first error. + * + * @see https://developer.wordpress.org/reference/classes/wp_error/ + */ + const wpError = response?.errors && Object.values( response.errors )?.[ 0 ]?.[ 0 ]; + if ( wpError ) { + throw new Error( wpError ); + } + + return response; +}; + const createDefaultProduct = async ( productType, setSelectedProductId, @@ -242,3 +271,24 @@ export const getNewsletterCategories = } executionLock.release( lock ); }; + +export const getNewsletterCategoriesSubscriptionsCount = + ( termIds = [] ) => + async ( { dispatch, registry } ) => { + await executionLock.blockExecution( + GET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT_EXECUTION_KEY + ); + + const lock = executionLock.acquire( + GET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT_EXECUTION_KEY + ); + + try { + const response = await fetchNewsletterCategoriesSubscriptionsCount( termIds ); + dispatch( setNewsletterCategoriesSubscriptionsCount( response.subscriptions_count ) ); + } catch ( error ) { + dispatch( setApiState( API_STATE_NOTCONNECTED ) ); + onError( error.message, registry ); + } + executionLock.release( lock ); + }; diff --git a/projects/plugins/jetpack/extensions/store/membership-products/selectors.js b/projects/plugins/jetpack/extensions/store/membership-products/selectors.js index c4e1143d1d6fa..661a0c9f0570f 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/selectors.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/selectors.js @@ -30,3 +30,6 @@ export const getSubscriberCounts = state => state.subscriberCounts; export const getNewsletterCategories = state => state.newsletterCategories.categories; export const getNewsletterCategoriesEnabled = state => state.newsletterCategories.enabled; + +export const getNewsletterCategoriesSubscriptionsCount = state => + state.newsletterCategoriesSubscriptionsCount; diff --git a/projects/plugins/jetpack/extensions/store/membership-products/test/actions-test.js b/projects/plugins/jetpack/extensions/store/membership-products/test/actions-test.js index 02d8060e932bc..93a56beea3cba 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/test/actions-test.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/test/actions-test.js @@ -10,6 +10,7 @@ import { setSiteSlug, setConnectedAccountDefaultCurrency, setNewsletterCategories, + setNewsletterCategoriesSubscriptionsCount, } from '../actions'; import * as utils from '../utils'; @@ -315,4 +316,18 @@ describe( 'Membership Products Actions', () => { // Then expect( result ).toStrictEqual( anyValidNewsletterCategoriesWithType ); } ); + + test( 'Set newsletter categories subscriptions count works as expected', () => { + // Given + const anyValidNewsletterCategoriesSubscriptionsCountWithType = { + type: 'SET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT', + newsletterCategoriesSubscriptionsCount: ANY_VALID_DATA, + }; + + // When + const result = setNewsletterCategoriesSubscriptionsCount( ANY_VALID_DATA ); + + // Then + expect( result ).toStrictEqual( anyValidNewsletterCategoriesSubscriptionsCountWithType ); + } ); } ); diff --git a/projects/plugins/jetpack/extensions/store/membership-products/test/reducer-test.js b/projects/plugins/jetpack/extensions/store/membership-products/test/reducer-test.js index 6edb056142415..4769f6eeecb15 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/test/reducer-test.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/test/reducer-test.js @@ -127,4 +127,25 @@ describe( 'Membership products reducer testing', () => { newsletterCategories: anyNewsletterCategories, } ); } ); + + test( 'set newsletter categories subscriptions count action type adds the newsletter categories subscriptions count to the returned state.', () => { + // Given + const anyNewsletterCategoriesSubscriptionsCount = 1; + const anySetNewsletterCategoriesSubscriptionsCountAction = { + type: 'SET_NEWSLETTER_CATEGORIES_SUBSCRIPTIONS_COUNT', + newsletterCategoriesSubscriptionsCount: anyNewsletterCategoriesSubscriptionsCount, + }; + + // When + const returnedState = reducer( + DEFAULT_STATE, + anySetNewsletterCategoriesSubscriptionsCountAction + ); + + // Then + expect( returnedState ).toStrictEqual( { + ...DEFAULT_STATE, + newsletterCategoriesSubscriptionsCount: anyNewsletterCategoriesSubscriptionsCount, + } ); + } ); } ); diff --git a/projects/plugins/jetpack/extensions/store/membership-products/test/selectors-test.js b/projects/plugins/jetpack/extensions/store/membership-products/test/selectors-test.js index 03c3aead2de6d..665a0c022778b 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/test/selectors-test.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/test/selectors-test.js @@ -1,6 +1,7 @@ import { getNewsletterCategories, getNewsletterCategoriesEnabled, + getNewsletterCategoriesSubscriptionsCount, getNewsletterProducts, getProducts, } from '../selectors'; @@ -45,4 +46,14 @@ describe( 'Membership Products Selectors', () => { state.newsletterCategories.enabled ); } ); + + test( 'getNewsletterCategoriesSubscriptionsCount works as expected', () => { + const state = { + newsletterCategoriesSubscriptionsCount: 1, + }; + + expect( getNewsletterCategoriesSubscriptionsCount( state ) ).toStrictEqual( + state.newsletterCategoriesSubscriptionsCount + ); + } ); } ); diff --git a/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php b/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php index dfd189b346477..6df8164c99c45 100644 --- a/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php +++ b/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php @@ -603,6 +603,13 @@ public static function user_can_view_post( $post_id = null ) { $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level ); + if ( $can_view_post && $post_access_level !== Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY ) { + // Prevent batcache to cache paywalled content + if ( function_exists( 'batcache_cancel' ) ) { + batcache_cancel(); + } + } + self::$user_can_view_post_cache[ $cache_key ] = $can_view_post; return $can_view_post; } diff --git a/projects/plugins/mu-wpcom-plugin/changelog/add-migrate-block-patterns b/projects/plugins/mu-wpcom-plugin/changelog/add-migrate-block-patterns new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/add-migrate-block-patterns @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + +