From bdc5c30731e869864bd82aef841277465493585a Mon Sep 17 00:00:00 2001 From: abaicus Date: Fri, 22 Nov 2024 19:16:50 +0200 Subject: [PATCH 1/6] feat: template cloud integration [ref Codeinwp/templates-cloud#98] --- inc/class-main.php | 10 +- inc/class-template-cloud.php | 342 ++++++++++++++++++ inc/plugins/class-dashboard.php | 7 +- inc/plugins/class-options-settings.php | 36 +- inc/server/class-template-cloud-server.php | 328 +++++++++++++++++ src/blocks/global.d.ts | 3 + .../cloudLibraryPlaceholder.js | 25 ++ .../plugins/patterns-library/editor.scss | 32 +- .../plugins/patterns-library/library.js | 187 ++++++---- .../components/pages/Integrations.js | 5 +- .../template-cloud/AddSourceForm.js | 90 +++++ .../components/template-cloud/Sources.js | 85 +++++ .../components/template-cloud/TCPanel.js | 146 ++++++++ .../components/template-cloud/common.js | 12 + src/dashboard/style.scss | 56 +++ tests/test-template-cloud.php | 202 +++++++++++ 16 files changed, 1486 insertions(+), 80 deletions(-) create mode 100644 inc/class-template-cloud.php create mode 100644 inc/server/class-template-cloud-server.php create mode 100644 src/blocks/plugins/patterns-library/cloudLibraryPlaceholder.js create mode 100644 src/dashboard/components/template-cloud/AddSourceForm.js create mode 100644 src/dashboard/components/template-cloud/Sources.js create mode 100644 src/dashboard/components/template-cloud/TCPanel.js create mode 100644 src/dashboard/components/template-cloud/common.js create mode 100644 tests/test-template-cloud.php diff --git a/inc/class-main.php b/inc/class-main.php index 814eca3c6..95e7597ac 100644 --- a/inc/class-main.php +++ b/inc/class-main.php @@ -48,7 +48,7 @@ public function init() { } add_filter( 'otter_blocks_about_us_metadata', array( $this, 'about_page' ) ); - + add_action( 'parse_query', array( $this, 'pagination_support' ) ); } @@ -83,6 +83,8 @@ public function autoload_classes() { '\ThemeIsle\GutenbergBlocks\Integration\Form_Email', '\ThemeIsle\GutenbergBlocks\Server\Form_Server', '\ThemeIsle\GutenbergBlocks\Server\Prompt_Server', + '\ThemeIsle\GutenbergBlocks\Template_Cloud', + '\ThemeIsle\GutenbergBlocks\Server\Template_Cloud_Server', ); $classnames = apply_filters( 'otter_blocks_autoloader', $classnames ); @@ -532,13 +534,13 @@ public function generate_svg_attachment_metadata( $metadata, $attachment_id ) { /** * Disable canonical redirect to make Posts pagination feature work. - * + * * @param \WP_Query $request The query object. */ public function pagination_support( $request ) { if ( - true === $request->is_singular && - -1 === $request->current_post && + true === $request->is_singular && + -1 === $request->current_post && true === $request->is_paged && ( ! empty( $request->query_vars['page'] ) || diff --git a/inc/class-template-cloud.php b/inc/class-template-cloud.php new file mode 100644 index 000000000..17698ddb2 --- /dev/null +++ b/inc/class-template-cloud.php @@ -0,0 +1,342 @@ +register_pattern_categories(); + $this->register_patterns(); + } + + /** + * Register the pattern categories. + * + * @return void + */ + private function register_pattern_categories() { + $sources = $this->get_pattern_sources(); + + if ( empty( $sources ) ) { + return; + } + + foreach ( $sources as $source ) { + $slug = $this->slug_from_name( $source['name'] ); + + if ( ! \WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( $slug ) ) { + register_block_pattern_category( $slug, [ 'label' => $source['name'] ] ); + } + } + } + + /** + * Register the patterns. + * + * @return void + */ + private function register_patterns() { + $cloud_data = $this->get_cloud_data(); + + if ( empty( $cloud_data ) ) { + return; + } + + $all_patterns = []; + + foreach ( $cloud_data as $source_data ) { + $patterns_for_source = []; + + if ( ! is_array( $source_data ) || ! isset( $source_data['patterns'], $source_data['category'] ) ) { + continue; + } + + $patterns = $source_data['patterns']; + $category = $source_data['category']; + + // Make sure we don't have duplicates. + foreach ( $patterns as $pattern ) { + if ( isset( $patterns_for_source[ $pattern['id'] ] ) ) { + continue; + } + + $pattern['categories'] = [ 'otter-blocks', 'otter-blocks-tc', $category ]; + + $patterns_for_source[ $pattern['id'] ] = $pattern; + } + + $all_patterns = array_merge( $all_patterns, $patterns_for_source ); + } + + foreach ( $all_patterns as $pattern ) { + if ( ! isset( $pattern['slug'] ) ) { + continue; + } + + + register_block_pattern( + 'otter-blocks/' . $pattern['slug'], + $pattern + ); + } + } + + /** + * Get all the cloud data for each source. + * + * @return array|array[] + */ + private function get_cloud_data() { + $sources = self::get_pattern_sources(); + + if ( empty( $sources ) ) { + return []; + } + + return array_map( + function ( $source ) { + return [ + 'category' => $this->slug_from_name( $source['name'] ), + 'patterns' => $this->get_patterns_for_key( $source['key'] ), + ]; + }, + $sources + ); + } + + /** + * Get patterns for a certain access key. + * + * @param string $access_key The access key. + * + * @return array + */ + private function get_patterns_for_key( $access_key ) { + $patterns = get_transient( self::get_cache_key( $access_key ) ); + + if ( ! $patterns ) { + self::sync_sources(); + } + + $patterns = get_transient( self::get_cache_key( $access_key ) ); + + if ( ! $patterns ) { + return []; + } + + $patterns = json_decode( $patterns, true ); + + return is_array( $patterns ) ? $patterns : array(); + } + + /** + * Get the slug from a name. + * + * @param string $name The name to slugify. + * + * @return string + */ + private function slug_from_name( $name ) { + return 'ti-tc-' . sanitize_key( str_replace( ' ', '-', $name ) ); + } + + /** + * Get the pattern sources. + * + * @return array + */ + public static function get_pattern_sources() { + return get_option( self::SOURCES_SETTING_KEY, [] ); + } + + /** + * Save the pattern sources. + * + * @param array $new_sources The new sources. + * + * @return bool + */ + public static function save_pattern_sources( $new_sources ) { + return update_option( self::SOURCES_SETTING_KEY, array_values( $new_sources ) ); + } + + /** + * Get the cache key for the patterns. + * + * @param string $key The key to use for connection. + * + * @return string + */ + public static function get_cache_key( $key ) { + return 'ti_tc_patterns_' . $key; + } + + /** + * Save patterns for a certain access key. + * + * @param string $access_key The access key. + * @param array $patterns The patterns to save. + * + * @return bool + */ + public static function save_patterns_for_key( $access_key, $patterns ) { + return set_transient( self::get_cache_key( $access_key ), wp_json_encode( $patterns ), DAY_IN_SECONDS ); + } + + /** + * Delete patterns for a certain access key. + * + * @param string $access_key The access key. + * + * @return bool + */ + public static function delete_patterns_by_key( $access_key ) { + return delete_transient( self::get_cache_key( $access_key ) ); + } + + /** + * Sync sources. + */ + public static function sync_sources() { + $sources = self::get_pattern_sources(); + + if ( empty( $sources ) ) { + return [ 'success' => true ]; + } + + $errors = array(); + + + + foreach ( $sources as $source ) { + $url = trailingslashit( $source['url'] ) . self::API_ENDPOINT_SUFFIX; + $args = array( + 'sslverify' => false, + 'headers' => array( + 'X-API-KEY' => $source['key'], + ), + ); + + if ( function_exists( 'vip_safe_wp_remote_get' ) ) { + $response = vip_safe_wp_remote_get( $url, '', 3, 1, 20, $args ); + } else { + $response = wp_remote_get( $url, $args ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get + } + + if ( is_wp_error( $response ) ) { + $errors[] = sprintf( + /* translators: 1: source name, 2: error message */ + __( 'Error with %1$s: %2$s', 'otter-blocks' ), + $source['name'], + $response->get_error_message() + ); + + continue; + } + + $code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== $code ) { + $errors[] = sprintf( + /* translators: 1: source name, 2: response code */ + __( 'Error with %1$s: Invalid response code %2$s', 'otter-blocks' ), + $source['name'], + $code + ); + + continue; + } + + $body = wp_remote_retrieve_body( $response ); + + if ( empty( $body ) ) { + $errors[] = sprintf( + /* translators: %s: source name */ + __( 'Error with %s: Empty response', 'otter-blocks' ), + $source['name'] + ); + + continue; + } + + $decoded_body = json_decode( $body, true ); + + if ( ! is_array( $decoded_body ) ) { + $errors[] = sprintf( + /* translators: %s: source name */ + __( 'Error with %s: Invalid response', 'otter-blocks' ), + $source['name'] + ); + + continue; + } + + if ( ! isset( $decoded_body['success'], $decoded_body['data'], $decoded_body['key_name'] ) || ! $decoded_body['success'] ) { + $errors[] = sprintf( + /* translators: %s: source name */ + __( 'Error with %s: No patterns found', 'otter-blocks' ), + $source['name'] + ); + + continue; + } + + // Update key name if that has changed. + if ( $decoded_body['key_name'] !== $source['name'] ) { + self::update_source_name( $source['key'], $decoded_body['key_name'] ); + } + + self::save_patterns_for_key( $source['key'], $decoded_body['data'] ); + } + + return [ + 'success' => true, + 'errors' => $errors, + ]; + } + + /** + * Update Source Name on sync. + * + * @param string $key The key to use for connection. + * @param string $new_name The new name to use. + * + * @return void + */ + public static function update_source_name( $key, $new_name ) { + $sources = self::get_pattern_sources(); + + foreach ( $sources as $idx => $source ) { + if ( $source['key'] === $key ) { + $sources[ $idx ]['name'] = $new_name; + } + } + + self::save_pattern_sources( $sources ); + } +} diff --git a/inc/plugins/class-dashboard.php b/inc/plugins/class-dashboard.php index df3306463..2a007424e 100644 --- a/inc/plugins/class-dashboard.php +++ b/inc/plugins/class-dashboard.php @@ -60,7 +60,7 @@ public function register_menu_page() { /** * Add shortcut to the Blocks tab in Dashboard. */ - + add_submenu_page( 'otter', @@ -122,7 +122,7 @@ public function add_inline_css() { } is_dir( $basedir ) || boolval( get_transient( 'otter_animations_parsed' ) ), 'hasPro' => Pro::is_pro_installed(), 'upgradeLink' => tsdk_utmify( Pro::get_url(), 'options', Pro::get_reference() ), + 'upgradeLinkFromTc' => tsdk_utmify( Pro::get_url(), 'templatecloud' ), + 'tcUpgradeLink' => tsdk_utmify( 'https://themeisle.com/plugins/templates-cloud/', 'templatecloud', 'otter-blocks' ), + 'tcDocs' => 'https://docs.themeisle.com/article/2191-templates-cloud-collections', 'docsLink' => Pro::get_docs_url(), 'showFeedbackNotice' => $this->should_show_feedback_notice(), 'deal' => ! Pro::is_pro_installed() ? $offer->get_localized_data() : array(), diff --git a/inc/plugins/class-options-settings.php b/inc/plugins/class-options-settings.php index 1d55fe515..bb8ebfd15 100644 --- a/inc/plugins/class-options-settings.php +++ b/inc/plugins/class-options-settings.php @@ -7,6 +7,9 @@ namespace ThemeIsle\GutenbergBlocks\Plugins; +use ThemeIsle\GutenbergBlocks\Server\Template_Cloud_Server; +use ThemeIsle\GutenbergBlocks\Template_Cloud; + /** * Class Options_Settings */ @@ -756,7 +759,7 @@ function ( $item ) { 'default' => true, ) ); - + register_setting( 'themeisle_blocks_settings', 'themeisle_blocks_settings_prompt_actions', @@ -813,6 +816,37 @@ function( $item ) { ), ) ); + + + register_setting( + 'themeisle_blocks_settings', + Template_Cloud::SOURCES_SETTING_KEY, + array( + 'type' => 'array', + 'description' => __( 'The template cloud sources from which patterns will be loaded.', 'otter-blocks' ), + 'sanitize_callback' => [ Template_Cloud_Server::class, 'sanitize_template_cloud_sources' ], + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'key' => array( + 'type' => 'string', + ), + 'url' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + 'default' => [], + ) + ); } /** diff --git a/inc/server/class-template-cloud-server.php b/inc/server/class-template-cloud-server.php new file mode 100644 index 000000000..9fd6ed65d --- /dev/null +++ b/inc/server/class-template-cloud-server.php @@ -0,0 +1,328 @@ + \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'add_source' ), + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => array( + 'key' => array( + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'url' => array( + 'required' => true, + 'sanitize_callback' => 'esc_url_raw', + ), + ), + ), + ) + ); + + register_rest_route( + self::API_NAMESPACE, + 'template-cloud/delete-source/(?P[a-zA-Z0-9-_]+)', + [ + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'remove_source' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'key' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ] + ); + + register_rest_route( + self::API_NAMESPACE, + 'template-cloud/sync', + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ $this, 'sync_sources' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); + } + + /** + * Validate the source and get the name. + * + * @param string $url The URL to validate. + * @param string $key The key to use for connection. + * + * @return string|false + */ + private function validate_source_and_get_name( $url, $key ) { + $url = trailingslashit( $url ) . self::API_ENDPOINT_SUFFIX; + $args = [ + 'sslverify' => false, + 'headers' => [ + 'X-API-KEY' => $key, + ], + ]; + + if ( function_exists( 'vip_safe_wp_remote_get' ) ) { + $response = vip_safe_wp_remote_get( $url, '', 3, 1, 20, $args ); + } else { + $response = wp_remote_get( $url, $args ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get + } + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $body = wp_remote_retrieve_body( $response ); + + if ( empty( $body ) ) { + return false; + } + + $data = json_decode( $body, true ); + + + if ( ! is_array( $data ) ) { + return false; + } + + if ( ! isset( $data['success'], $data['key_name'] ) || ! $data['success'] ) { + return false; + } + + return $data['key_name']; + } + + /** + * Add a source. + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response + */ + public function add_source( WP_REST_Request $request ) { + $params = $request->get_params(); + + if ( ! isset( $params['key'], $params['url'] ) ) { + return new WP_REST_Response( + array( + 'message' => __( 'Invalid request. Please provide a key and url.', 'otter-blocks' ), + ), + 400 + ); + } + + $name = $this->validate_source_and_get_name( $params['url'], $params['key'] ); + + if ( ! $name || ! is_string( $name ) ) { + return new WP_REST_Response( + array( + 'message' => __( 'Invalid source. Please make sure you have the correct key and url.', 'otter-blocks' ), + ), + 400 + ); + } + + $sources = Template_Cloud::get_pattern_sources(); + $keys = wp_list_pluck( $sources, 'key' ); + + if ( in_array( $params['key'], $keys ) ) { + return new WP_REST_Response( + array( + 'message' => __( 'Source already exists', 'otter-blocks' ), + ), + 400 + ); + } + + $sources[] = array( + 'key' => $params['key'], + 'url' => esc_url_raw( $params['url'] ), + 'name' => sanitize_text_field( $name ), + ); + + $update = Template_Cloud::save_pattern_sources( $sources ); + + if ( ! $update ) { + return new WP_REST_Response( + array( + 'message' => __( 'Failed to save the source', 'otter-blocks' ), + ), + 500 + ); + } + + $this->sync_sources(); + + return new WP_REST_Response( + array( + 'sources' => Template_Cloud::get_pattern_sources(), + ) + ); + } + + /** + * Remove a source. + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response + */ + public function remove_source( WP_REST_Request $request ) { + $params = $request->get_params(); + + if ( ! isset( $params['key'] ) ) { + return new WP_REST_Response( + array( + 'message' => __( 'Key is missing', 'otter-blocks' ), + ), + 400 + ); + } + + $sources = Template_Cloud::get_pattern_sources(); + + $filtered_sources = array_filter( + $sources, + function ( $source ) use ( $params ) { + return $params['key'] !== $source['key']; + } + ); + + $update = Template_Cloud::save_pattern_sources( $filtered_sources ); + + if ( ! $update ) { + return new WP_REST_Response( + array( + 'message' => __( 'Failed to remove the source', 'otter-blocks' ), + ), + 500 + ); + } + + Template_Cloud::delete_patterns_by_key( $params['key'] ); + + $this->sync_sources(); + + return new WP_REST_Response( + array( + 'success' => true, + 'sources' => Template_Cloud::get_pattern_sources(), + ) + ); + } + + /** + * Sync sources. + * + * @return WP_REST_Response + */ + public function sync_sources() { + $sources = Template_Cloud::get_pattern_sources(); + + if ( empty( $sources ) ) { + return new WP_REST_Response( + array( + 'message' => __( 'No sources to sync', 'otter-blocks' ), + ), + 400 + ); + } + + $sync = Template_Cloud::sync_sources(); + + if ( ! is_array( $sync ) || ! isset( $sync['success'] ) || ! $sync['success'] ) { + return new WP_REST_Response( + array( + 'message' => __( 'Failed to sync sources', 'otter-blocks' ), + ), + 500 + ); + } + + return new WP_REST_Response( + array( + 'success' => $sync['success'], + 'errors' => $sync['errors'], + 'sources' => Template_Cloud::get_pattern_sources(), + ) + ); + } + + /** + * Sanitize the template cloud sources array when saving the setting. + * + * @param array $value The value to sanitize. + * + * @return array[] + */ + public static function sanitize_template_cloud_sources( $value ) { + if ( ! is_array( $value ) ) { + return array(); + } + + foreach ( $value as $idx => $source_data ) { + $allowed_keys = [ 'key', 'url', 'name' ]; + + foreach ( $source_data as $key => $val ) { + if ( ! in_array( $key, $allowed_keys, true ) ) { + unset( $value[ $idx ][ $key ] ); + + continue; + } + + if ( 'url' !== $key ) { + $source_data[ $key ] = esc_url_raw( $val ); + + continue; + } + + $source_data[ $key ] = sanitize_text_field( $val ); + } + } + + return $value; + } +} diff --git a/src/blocks/global.d.ts b/src/blocks/global.d.ts index fa844ab46..be00a634e 100644 --- a/src/blocks/global.d.ts +++ b/src/blocks/global.d.ts @@ -114,6 +114,9 @@ declare global { storeURL: string styleExist: string upgradeLink: string + upgradeLinkFromTc: string + tcUpgradeLink: string + tcDocs: string version: string }>> themeisleGutenbergCountdown: { diff --git a/src/blocks/plugins/patterns-library/cloudLibraryPlaceholder.js b/src/blocks/plugins/patterns-library/cloudLibraryPlaceholder.js new file mode 100644 index 000000000..8520d203e --- /dev/null +++ b/src/blocks/plugins/patterns-library/cloudLibraryPlaceholder.js @@ -0,0 +1,25 @@ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { cloud, Icon } from '@wordpress/icons'; + +export default () => ( +
+ + +

{__('Add External Sources', 'otter-blocks')}

+ +
+

{__('Import templates from any site using Templates Cloud.', 'otter-blocks')}

+

{__('Share patterns between your own sites.', 'otter-blocks')}

+
+ +
+ + +
+); diff --git a/src/blocks/plugins/patterns-library/editor.scss b/src/blocks/plugins/patterns-library/editor.scss index 983aac3cb..ac59c047b 100644 --- a/src/blocks/plugins/patterns-library/editor.scss +++ b/src/blocks/plugins/patterns-library/editor.scss @@ -136,4 +136,34 @@ } } } -} \ No newline at end of file + + &__tc-placeholder { + border: 2px dashed #e5e7eb; + border-radius: 8px; + padding: 20px 15px; + display: flex; + flex-direction: column; + align-items: center; + + svg { + fill: #9ca3af; + margin-bottom: 16px; + } + + h2 { + font-size: 18px; + margin: 0 0 12px; + } + + p { + font-size: 14px; + margin: 0; + text-align: center; + } + + a { + font-size: 14px; + text-decoration: none !important; + } + } +} diff --git a/src/blocks/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js index 5a14e0996..0fe9c7272 100644 --- a/src/blocks/plugins/patterns-library/library.js +++ b/src/blocks/plugins/patterns-library/library.js @@ -36,6 +36,9 @@ import { grid } from '@wordpress/icons'; import { insertBlockBelow } from '../../helpers/block-utility'; import Template from './template'; import Preview from './preview'; +import CloudLibraryPlaceholder from './cloudLibraryPlaceholder'; + +const CLOUD_EMPTY_CATEGORY = 'cloud-empty'; const Library = ({ onClose @@ -69,6 +72,7 @@ const Library = ({ const { patterns, categories, + tcCategories, isResolvingPatterns } = useSelect( ( select ) => { const { @@ -81,9 +85,13 @@ const Library = ({ const allCategories = getBlockPatternCategories(); - const patternCategories = [ ...new Set( patterns.flatMap( pattern => pattern.categories ) ) ]; + const patternCategories = [ ...new Set( patterns.flatMap( pattern => pattern.categories.filter( category => { + return ! category.startsWith( 'ti-tc-' ); + } ) ) ) ]; + const tcPatternCategories = [ ...new Set( patterns.flatMap( pattern => pattern.categories.filter( category => { return category.startsWith( 'ti-tc-' ); } ) ) ) ]; const categories = [ ...allCategories.filter( category => patternCategories.includes( category?.name ) ) ]; + const tcCategories = [ ...allCategories.filter( category => tcPatternCategories.includes( category?.name ) ) ]; categories.forEach( category => { if ( 'otter-blocks' === category?.name ) { @@ -126,7 +134,8 @@ const Library = ({ categories.splice( allCategoryIndex + 1, 0, ...packCategories ); return { - patterns: patterns.filter( pattern => pattern.categories.includes( 'otter-blocks' ) ), + patterns, + tcCategories, categories, isResolvingPatterns: isResolving( 'core', 'getBlockPatterns' ) || isResolving( 'core', 'getBlockPatternCategories' ) }; @@ -214,6 +223,37 @@ const Library = ({ ) } + + {tcCategories.length > 0 && ( + <> +

+ {__('Cloud Libraries', 'otter-blocks')} +

+ + + + )} + {tcCategories.length < 1 && ( + + ) } +

{ __( 'Categories', 'otter-blocks' ) }

@@ -233,93 +273,98 @@ const Library = ({
-
- - - setLayout( 2 ) - }, - { - title: __( '3 Column', 'otter-blocks' ), - onClick: () => setLayout( 3 ) - }, - { - title: __( '4 Column', 'otter-blocks' ), - onClick: () => setLayout( 4 ) - } - ] } - /> -
- -
} + {selectedCategory !== CLOUD_EMPTY_CATEGORY && ( + <> +
+ + + setLayout(2) + }, + { + title: __('3 Column', 'otter-blocks'), + onClick: () => setLayout(3) + }, + { + title: __('4 Column', 'otter-blocks'), + onClick: () => setLayout(4) + } + ]} + /> +
+ +
- { ! filteredPatterns.length && ( -

- { __( 'No patterns found.', 'otter-blocks' ) } -

- ) } - - { filteredPatterns.map( pattern => ( -