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-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/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js index 5a14e0996..3e02f1ad4 100644 --- a/src/blocks/plugins/patterns-library/library.js +++ b/src/blocks/plugins/patterns-library/library.js @@ -69,6 +69,7 @@ const Library = ({ const { patterns, categories, + tcCategories, isResolvingPatterns } = useSelect( ( select ) => { const { @@ -81,9 +82,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 +131,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 +220,28 @@ const Library = ({ ) } + + {tcCategories.length > 0 && ( + <> +

+ {__('My library', 'otter-blocks')} +

+ + + + ) } +

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

diff --git a/src/dashboard/components/pages/Integrations.js b/src/dashboard/components/pages/Integrations.js index b6d36dcfc..d95e7d137 100644 --- a/src/dashboard/components/pages/Integrations.js +++ b/src/dashboard/components/pages/Integrations.js @@ -30,6 +30,7 @@ import { applyFilters } from '@wordpress/hooks'; * Internal dependencies. */ import useSettings from '../../../blocks/helpers/use-settings.js'; +import TCPanel from '../template-cloud/TCPanel'; const Integrations = () => { const [ getOption, updateOption, status ] = useSettings(); @@ -302,7 +303,7 @@ const Integrations = () => { } ); - + } } } > @@ -395,6 +396,8 @@ const Integrations = () => { ) } + + ); }; diff --git a/src/dashboard/components/template-cloud/AddSourceForm.js b/src/dashboard/components/template-cloud/AddSourceForm.js new file mode 100644 index 000000000..c89be7b86 --- /dev/null +++ b/src/dashboard/components/template-cloud/AddSourceForm.js @@ -0,0 +1,90 @@ +import { __ } from '@wordpress/i18n'; +import { Button, Notice, TextControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { plus } from '@wordpress/icons'; +import apiFetch from '@wordpress/api-fetch'; +import { useDispatch } from '@wordpress/data'; +import { BUTTON_GROUP_STYLE } from './common'; + +const STATUSES = { + SAVING: 'saving', + NONE: 'none', +}; + +const AddSourceForm = ({ setSources, onCancel }) => { + const [ apiURL, setApiURL ] = useState(''); + const [ accessKey, setAccessKey ] = useState(''); + const [ status, setStatus ] = useState(STATUSES.NONE); + const [ error, setError ] = useState(''); + const { createNotice } = useDispatch('core/notices'); + + const isSaving = STATUSES.SAVING === status; + + const addSource = () => { + setStatus(STATUSES.SAVING); + + apiFetch({ + path: 'otter/v1/template-cloud/add-source', + method: 'POST', + accept: 'application/json', + data: { + url: apiURL, + key: accessKey, + } + }).then((response) => { + setStatus(STATUSES.NONE); + setSources(response.sources); + onCancel(); + createNotice( + 'success', + __('Source added successfully', 'otter-blocks'), + { + isDismissible: true, + type: 'snackbar' + } + ); + }).catch((e) => { + setStatus(STATUSES.NONE); + setError(e?.message ?? __('An unknown error occurred.', 'otter-blocks')); + }); + }; + return ( + <> + setApiURL(value)} + /> + setAccessKey(value)} + /> + + {error && {error}} + +
+ + + +
+ + ); +}; + +export default AddSourceForm; diff --git a/src/dashboard/components/template-cloud/Sources.js b/src/dashboard/components/template-cloud/Sources.js new file mode 100644 index 000000000..ceb2f9231 --- /dev/null +++ b/src/dashboard/components/template-cloud/Sources.js @@ -0,0 +1,85 @@ +import { BaseControl, Button, Notice } from '@wordpress/components'; +import { trash } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; + +const Sources = ({ sourcesData, setSources, isSyncing }) => { + const { createNotice } = useDispatch('core/notices'); + const [ error, setError ] = useState(''); + + const deleteSource = (key) => { + const confirm = window.confirm(__('Are you sure you want to delete this source?', 'otter-blocks')); + + if (!confirm) { + return; + } + + apiFetch({ + path: `otter/v1/template-cloud/delete-source/${key}`, + method: 'DELETE', + accept: 'application/json', + }).then((response) => { + setSources(response.sources); + createNotice( + 'success', + __('Source deleted', 'otter-blocks'), + { + isDismissible: true, + type: 'snackbar' + } + ); + }).catch((e) => { + setError(e?.message ?? __('An unknown error occurred.', 'otter-blocks')); + }); + }; + + return ( + <> + + + + + + + + + + {sourcesData.map((source) => { + const displayURL = new URL(source.url).hostname; + return ( + + + + + + ); + })} + +
{ __('Name', 'otter-blocks' )}{ __('Source URL', 'otter-blocks' )}{ __('Actions', 'otter-blocks' )}
+ {source.name} + + {displayURL} + +
+ + {error && { + setError(''); + }}>{error}} + + ); +}; + +export default Sources; diff --git a/src/dashboard/components/template-cloud/TCPanel.js b/src/dashboard/components/template-cloud/TCPanel.js new file mode 100644 index 000000000..e629753c9 --- /dev/null +++ b/src/dashboard/components/template-cloud/TCPanel.js @@ -0,0 +1,119 @@ +import { Button, Notice, PanelBody } from '@wordpress/components'; +import { useEffect, useState } from '@wordpress/element'; +import { plus, rotateRight } from '@wordpress/icons'; +import { useDispatch } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; + +import useSettings from '../../../blocks/helpers/use-settings'; +import { BUTTON_GROUP_STYLE, STATUSES } from './common'; +import AddSourceForm from './AddSourceForm'; +import Sources from './Sources'; + +const TCPanel = () => { + const [ getOption ] = useSettings(); + const [ sources, setSources ] = useState([]); + const [ isAdding, setIsAdding ] = useState(false); + const [ status, setStatus ] = useState(STATUSES.NONE); + const [ syncErrors, setSyncErrors ] = useState([]); + + const { createNotice } = useDispatch('core/notices'); + + useEffect(() => { + setSources(getOption('themeisle_template_cloud_sources')); + }, [ getOption('themeisle_template_cloud_sources') ]); + + const syncSources = () => { + setStatus(STATUSES.SYNCING); + + apiFetch({ + path: 'otter/v1/template-cloud/sync', + }).then((response) => { + const { sources, errors } = response; + + setSources(sources); + setStatus(STATUSES.NONE); + + if( errors.length > 0 ) { + setSyncErrors(errors); + } + + createNotice( + 'success', + __('Sources synced successfully.', 'otter-blocks'), + { + isDismissible: true, + type: 'snackbar' + } + ); + + }).catch((e) => { + setStatus(STATUSES.NONE); + setSyncErrors([ e?.message ?? __('An unknown error occurred.', 'otter-blocks') ]); + }); + }; + + const clearErrors = () => { + setSyncErrors([]); + }; + + const toggleAdding = () => { + setIsAdding(!isAdding); + }; + + const isSyncing = STATUSES.SYNCING === status; + + return <> + +
+ {sources.length < 1 && ! isAdding && ( +
+

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

+ +
+ )} + + {sources.length > 0 && ( + + )} + + {isAdding && ( + + )} + + {!isAdding && sources.length > 0 && ( + <> + {syncErrors.length > 0 && ( + + {syncErrors.map((error, index) => ( +

{error}

+ ))} +
+ )} + +
+ + +
+ + )} +
+
+ ; + +}; + +export default TCPanel; diff --git a/src/dashboard/components/template-cloud/common.js b/src/dashboard/components/template-cloud/common.js new file mode 100644 index 000000000..5ea3a9b39 --- /dev/null +++ b/src/dashboard/components/template-cloud/common.js @@ -0,0 +1,12 @@ +export const BUTTON_GROUP_STYLE = { + display: 'flex', + alignItems: 'center', + gap: '10px', + marginTop: '10px', +}; + +export const STATUSES = { + NONE: 'none', + SAVING: 'loading', + SYNCING: 'syncing' +}; diff --git a/src/dashboard/style.scss b/src/dashboard/style.scss index 4d6976eec..ee80f0dce 100644 --- a/src/dashboard/style.scss +++ b/src/dashboard/style.scss @@ -947,6 +947,56 @@ } } +.tc-panel-content-wrap { + padding: 20px 10px 10px; +} + +.tc-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 25px; + + thead { + font-weight: 500; + font-size: 14px; + } + + tr { + border-bottom: 1px solid #e2e4e7; + } + + td { + padding: 5px 10px; + + &:last-child { + text-align: right; + padding-right: 0; + } + + &:first-child { + padding-left: 0; + } + } + + button.is-destructive { + --wp-components-color-accent: #f84848; + --wp-components-color-accent-darker-10: #d93838; + } +} + +.tc-sources-empty { + text-align: center; + padding: 20px; + width: 100%; + border: 2px dashed #e2e4e7; + border-radius: 5px; + + h4 { + margin-top: 15px; + font-size: 20px; + } +} + @keyframes otter-neve-rotation { 0% { transform: rotate(0deg); diff --git a/tests/test-template-cloud.php b/tests/test-template-cloud.php new file mode 100644 index 000000000..a46c065ae --- /dev/null +++ b/tests/test-template-cloud.php @@ -0,0 +1,202 @@ +template_cloud = new Template_Cloud(); + $this->server = new Template_Cloud_Server(); + $this->server->instance(); + + do_action( 'rest_api_init', $wp_rest_server ); + } + + public function tear_down(): void { + delete_option( Template_Cloud::SOURCES_SETTING_KEY ); + parent::tear_down(); + } + + public function test_save_and_get_pattern_sources() { + $test_sources = [ + [ + 'key' => 'test-key-1', + 'url' => 'https://example.com', + 'name' => 'Test Source 1' + ] + ]; + + $result = Template_Cloud::save_pattern_sources( $test_sources ); + $this->assertTrue( $result ); + + $saved_sources = Template_Cloud::get_pattern_sources(); + $this->assertEquals( $test_sources, $saved_sources ); + } + + public function test_delete_patterns_by_key() { + $key = 'test-key'; + $patterns = [ 'test-pattern' ]; + + Template_Cloud::save_patterns_for_key( $key, $patterns ); + $result = Template_Cloud::delete_patterns_by_key( $key ); + + $this->assertTrue( $result ); + $this->assertFalse( get_transient( Template_Cloud::get_cache_key( $key ) ) ); + } + + public function test_update_source_name() { + $sources = [ + [ + 'key' => 'test-key', + 'url' => 'https://example.com', + 'name' => 'Old Name' + ] + ]; + + Template_Cloud::save_pattern_sources( $sources ); + Template_Cloud::update_source_name( 'test-key', 'New Name' ); + + $updated_sources = Template_Cloud::get_pattern_sources(); + $this->assertEquals( 'New Name', $updated_sources[0]['name'] ); + } + + public function test_add_source_invalid_source() { + $request = new WP_REST_Request( 'POST', '/otter/v1/template-cloud/add-source' ); + $request->set_param( 'key', 'test-key' ); + $request->set_param( 'url', 'https://invalid-url.com' ); + + $response = $this->server->add_source( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertArrayHasKey( 'message', $response->get_data() ); + } + + public function test_add_valid_source() { + // Mock the WP_REST_Request + $request = new WP_REST_Request( 'POST', '/otter/v1/template-cloud/add-source' ); + $request->set_param( 'key', 'test-key' ); + $request->set_param( 'url', 'https://example.com' ); + + // Use reflection to access private method + $reflection = new ReflectionClass( $this->server ); + $method = $reflection->getMethod( 'validate_source_and_get_name' ); + $method->setAccessible( true ); + + // Mock wp_remote_get response + add_filter( 'pre_http_request', function ( $preempt, $args, $url ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'success' => true, + 'key_name' => 'Test Source Name' + ] ) + ]; + }, 10, 3 ); + + // Test the method + $result = $method->invoke( $this->server, 'https://example.com', 'test-key' ); + $this->assertEquals( 'Test Source Name', $result ); + + // Test invalid response + add_filter( 'pre_http_request', function ( $preempt, $args, $url ) { + return [ + 'response' => [ 'code' => 400 ], + 'body' => '' + ]; + }, 10, 3 ); + + $result = $method->invoke( $this->server, 'https://example.com', 'test-key' ); + $this->assertFalse( $result ); + } + + public function test_remove_source() { + $sources = [ + [ + 'key' => 'test-key', + 'url' => 'https://example.com', + 'name' => 'Test Source' + ] + ]; + Template_Cloud::save_pattern_sources( $sources ); + + $request = new WP_REST_Request( 'DELETE', '/otter/v1/template-cloud/delete-source/test-key' ); + $request->set_param( 'key', 'test-key' ); + + $response = $this->server->remove_source( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['success'] ); + $this->assertEmpty( $response->get_data()['sources'] ); + } + + public function test_sync_sources_empty() { + $response = $this->server->sync_sources(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertArrayHasKey( 'message', $response->get_data() ); + } + + public function test_sanitize_template_cloud_sources() { + $input = [ + [ + 'key' => 'test-key', + 'url' => 'https://example.com', + 'name' => 'Test Source', + 'invalid_key' => 'should be removed' + ] + ]; + + $sanitized = Template_Cloud_Server::sanitize_template_cloud_sources( $input ); + + $this->assertArrayNotHasKey( 'invalid_key', $sanitized[0] ); + $this->assertCount( 3, array_keys( $sanitized[0] ) ); + } + + public function test_register_cloud_resources() { + $sources = [ + [ + 'key' => 'test-key', + 'url' => 'https://example.com', + 'name' => 'Test Source' + ] + ]; + Template_Cloud::save_pattern_sources( $sources ); + + $patterns = [ + [ + 'id' => 1, + 'title' => 'Test Pattern', + 'slug' => 'test-pattern', + 'content' => '

Test

' + ], + [ + 'id' => 2, + 'title' => 'Test Pattern 2', + 'slug' => 'test-pattern-2', + 'content' => '

Test

' + ], + ]; + + Template_Cloud::save_patterns_for_key( 'test-key', $patterns ); + + $this->template_cloud->register_cloud_resources(); + + $this->assertTrue( + \WP_Block_Patterns_Registry::get_instance()->is_registered( 'otter-blocks/test-pattern' ) + ); + + + $this->assertTrue( + \WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'ti-tc-test-source' ) + ); + } +}