diff --git a/composer.lock b/composer.lock index 66b2b67e..84a72ef8 100644 --- a/composer.lock +++ b/composer.lock @@ -1439,16 +1439,16 @@ }, { "name": "php-stubs/wordpress-tests-stubs", - "version": "v6.7.1", + "version": "v6.7.2", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-tests-stubs.git", - "reference": "37cc3f2136034cc239149151933e1c5be6ce6103" + "reference": "8aa4346a54022a6f3823a5621213e96862bb8fb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-tests-stubs/zipball/37cc3f2136034cc239149151933e1c5be6ce6103", - "reference": "37cc3f2136034cc239149151933e1c5be6ce6103", + "url": "https://api.github.com/repos/php-stubs/wordpress-tests-stubs/zipball/8aa4346a54022a6f3823a5621213e96862bb8fb0", + "reference": "8aa4346a54022a6f3823a5621213e96862bb8fb0", "shasum": "" }, "require-dev": { @@ -1467,9 +1467,9 @@ "keywords": [ "PHPStan", "static analysis", "wordpress" ], "support": { "issues": "https://github.com/php-stubs/wordpress-tests-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-tests-stubs/tree/v6.7.1" + "source": "https://github.com/php-stubs/wordpress-tests-stubs/tree/v6.7.2" }, - "time": "2024-11-23T22:07:27+00:00" + "time": "2025-02-11T16:45:49+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -2241,16 +2241,16 @@ }, { "name": "psalm/phar", - "version": "6.5.1", + "version": "6.6.1", "source": { "type": "git", "url": "https://github.com/psalm/phar.git", - "reference": "f7ecaf8740f32d58695cee5ac11663e02b44b223" + "reference": "6bd3007a43c220e6fa7774f07784e849cef828e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/psalm/phar/zipball/f7ecaf8740f32d58695cee5ac11663e02b44b223", - "reference": "f7ecaf8740f32d58695cee5ac11663e02b44b223", + "url": "https://api.github.com/repos/psalm/phar/zipball/6bd3007a43c220e6fa7774f07784e849cef828e8", + "reference": "6bd3007a43c220e6fa7774f07784e849cef828e8", "shasum": "" }, "require": { @@ -2266,9 +2266,9 @@ "description": "Composer-based Psalm Phar", "support": { "issues": "https://github.com/psalm/phar/issues", - "source": "https://github.com/psalm/phar/tree/6.5.1" + "source": "https://github.com/psalm/phar/tree/6.6.1" }, - "time": "2025-02-10T10:05:10+00:00" + "time": "2025-02-16T17:19:57+00:00" }, { "name": "sebastian/cli-parser", @@ -3153,16 +3153,16 @@ }, { "name": "sirbrillig/phpcs-variable-analysis", - "version": "v2.11.22", + "version": "v2.11.21", "source": { "type": "git", "url": "https://github.com/sirbrillig/phpcs-variable-analysis.git", - "reference": "ffb6f16c6033ec61ed84446b479a31d6529f0eb7" + "reference": "eb2b351927098c24860daa7484e290d3eed693be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/ffb6f16c6033ec61ed84446b479a31d6529f0eb7", - "reference": "ffb6f16c6033ec61ed84446b479a31d6529f0eb7", + "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/eb2b351927098c24860daa7484e290d3eed693be", + "reference": "eb2b351927098c24860daa7484e290d3eed693be", "shasum": "" }, "require": { @@ -3174,6 +3174,7 @@ "phpcsstandards/phpcsdevcs": "^1.1", "phpstan/phpstan": "^1.7", "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.5 || ^7.0 || ^8.0 || ^9.0 || ^10.5.32 || ^11.3.3", + "sirbrillig/phpcs-import-detection": "^1.1", "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0" }, "type": "phpcodesniffer-standard", @@ -3201,7 +3202,7 @@ "source": "https://github.com/sirbrillig/phpcs-variable-analysis", "wiki": "https://github.com/sirbrillig/phpcs-variable-analysis/wiki" }, - "time": "2025-01-06T17:54:24+00:00" + "time": "2024-12-02T16:37:49+00:00" }, { "name": "slevomat/coding-standard", @@ -3265,16 +3266,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.3", + "version": "3.11.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10" + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10", - "reference": "ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1368f4a58c3c52114b86b1abe8f4098869cb0079", + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079", "shasum": "" }, "require": { @@ -3330,13 +3331,9 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" - }, - { - "url": "https://thanks.dev/phpcsstandards", - "type": "thanks_dev" } ], - "time": "2025-01-23T17:04:15+00:00" + "time": "2024-12-11T16:04:26+00:00" }, { "name": "theseer/tokenizer", @@ -3445,16 +3442,16 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "6.7.1", + "version": "6.7.2", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", - "reference": "e63eb1c0980839853c569d3f04ff70263b7795e3" + "reference": "e2bb06bacc92a8e9e405e83f56989e8ed9359db1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/e63eb1c0980839853c569d3f04ff70263b7795e3", - "reference": "e63eb1c0980839853c569d3f04ff70263b7795e3", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/e2bb06bacc92a8e9e405e83f56989e8ed9359db1", + "reference": "e2bb06bacc92a8e9e405e83f56989e8ed9359db1", "shasum": "" }, "type": "library", @@ -3481,7 +3478,7 @@ "issues": "https://github.com/wp-phpunit/issues", "source": "https://github.com/wp-phpunit/wp-phpunit" }, - "time": "2024-11-22T01:27:46+00:00" + "time": "2025-02-12T01:22:52+00:00" }, { "name": "yoast/phpunit-polyfills", @@ -3547,6 +3544,6 @@ "platform": { "php": ">=8.1" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/docs/extending/block-registration.md b/docs/extending/block-registration.md index 90f9ba75..7902cb85 100644 --- a/docs/extending/block-registration.md +++ b/docs/extending/block-registration.md @@ -70,7 +70,7 @@ The render query is executed when the block is rendered and fetches the data tha ### `selection_queries`: array (optional) -Selection queries are used by content creators to select or curate remote data in the block editor. For example, you may wish to provide a list of products to users and allow them to select one to incled, or you may want to allow a user to search for a specific item. Selection queries are an array of objects with the following properties: +Selection queries are used by content creators to select or curate remote data in the block editor. For example, you may wish to provide a list of products to users and allow them to select one to include in their post, or you may want to allow a user to search for a specific item. Selection queries are an array of objects with the following properties: - `display_name`: A human-friendly name for the selection query. - `query` (required): An instance of `QueryInterface` that fetches the data. diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 436ef480..7259a775 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -203,6 +203,11 @@ public function execute( HttpQueryInterface $query, array $input_variables ): ar if ( ! array_key_exists( $key, $input_variables ) && isset( $schema['default_value'] ) ) { $input_variables[ $key ] = $schema['default_value']; } + + // If the input variable is required and not provided, return an error. + if ( ! array_key_exists( $key, $input_variables ) && isset( $schema['required'] ) && $schema['required'] ) { + return new WP_Error( 'remote-data-blocks-missing-required-input-variable', sprintf( 'Missing required input variable: %s', $key ) ); + } } $raw_response_data = $this->get_raw_response_data( $query, $input_variables ); diff --git a/inc/Editor/BlockManagement/BlockRegistration.php b/inc/Editor/BlockManagement/BlockRegistration.php index c0be1645..67d3e0ce 100644 --- a/inc/Editor/BlockManagement/BlockRegistration.php +++ b/inc/Editor/BlockManagement/BlockRegistration.php @@ -4,7 +4,7 @@ defined( 'ABSPATH' ) || exit(); -use RemoteDataBlocks\Analytics\TracksAnalytics; +use RemoteDataBlocks\Telemetry\TracksTelemetry; use RemoteDataBlocks\Editor\BlockPatterns\BlockPatterns; use RemoteDataBlocks\Editor\DataBinding\BlockBindings; use RemoteDataBlocks\REST\RemoteDataController; @@ -54,7 +54,7 @@ public static function register_container_blocks(): void { wp_localize_script( $script_handle, 'REMOTE_DATA_BLOCKS', [ 'config' => $all_remote_block_configs, 'rest_url' => RemoteDataController::get_url(), - 'tracks_global_properties' => TracksAnalytics::get_global_properties(), + 'tracks_global_properties' => TracksTelemetry::get_global_properties(), ] ); } } diff --git a/inc/Integrations/Airtable/AirtableIntegration.php b/inc/Integrations/Airtable/AirtableIntegration.php index c0ff3eb7..e526f385 100644 --- a/inc/Integrations/Airtable/AirtableIntegration.php +++ b/inc/Integrations/Airtable/AirtableIntegration.php @@ -2,7 +2,7 @@ namespace RemoteDataBlocks\Integrations\Airtable; -use RemoteDataBlocks\WpdbStorage\DataSourceCrud; +use RemoteDataBlocks\Store\DataSource\DataSourceConfigManager; use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Formatting\StringFormatter; use RemoteDataBlocks\Snippet\Snippet; @@ -14,17 +14,14 @@ public static function init(): void { } public static function register_blocks(): void { - $data_source_configs = DataSourceCrud::get_configs_by_service( - REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE - ); + $data_source_configs = DataSourceConfigManager::get_all( [ + 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, + 'enable_blocks' => true, + ] ); foreach ( $data_source_configs as $config ) { $data_source = AirtableDataSource::from_array( $config ); - if ( false === ( $config['service_config']['enable_blocks'] ?? true ) ) { - continue; - } - self::register_blocks_for_airtable_data_source( $data_source ); self::register_loop_blocks_for_airtable_data_source( $data_source ); } diff --git a/inc/Integrations/Google/Sheets/GoogleSheetsIntegration.php b/inc/Integrations/Google/Sheets/GoogleSheetsIntegration.php index 8b4f25c0..19643d97 100644 --- a/inc/Integrations/Google/Sheets/GoogleSheetsIntegration.php +++ b/inc/Integrations/Google/Sheets/GoogleSheetsIntegration.php @@ -2,7 +2,7 @@ namespace RemoteDataBlocks\Integrations\Google\Sheets; -use RemoteDataBlocks\WpdbStorage\DataSourceCrud; +use RemoteDataBlocks\Store\DataSource\DataSourceConfigManager; use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Formatting\StringFormatter; use RemoteDataBlocks\Snippet\Snippet; @@ -14,17 +14,14 @@ public static function init(): void { } public static function register_blocks(): void { - $data_source_configs = DataSourceCrud::get_configs_by_service( - REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE - ); + $data_source_configs = DataSourceConfigManager::get_all( [ + 'service' => REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE, + 'enable_blocks' => true, + ] ); foreach ( $data_source_configs as $config ) { $data_source = GoogleSheetsDataSource::from_array( $config ); - - if ( false === ( $config['service_config']['enable_blocks'] ?? true ) ) { - continue; - } - + self::register_blocks_for_google_sheets_data_source( $data_source ); self::register_loop_blocks_for_google_sheets_data_source( $data_source ); } diff --git a/inc/Integrations/SalesforceB2C/Auth/SalesforceB2CAuth.php b/inc/Integrations/SalesforceB2C/Auth/SalesforceB2CAuth.php deleted file mode 100644 index 029d938f..00000000 --- a/inc/Integrations/SalesforceB2C/Auth/SalesforceB2CAuth.php +++ /dev/null @@ -1,247 +0,0 @@ - [ - 'grant_type' => 'client_credentials', - 'channel_id' => 'RefArch', - ], - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'Authorization' => 'Basic ' . $client_credentials, - ], - ]); - - if ( is_wp_error( $client_auth_response ) ) { - return new WP_Error( - 'salesforce_b2c_auth_error_client_credentials', - __( 'Failed to retrieve access token from client credentials', 'remote-data-blocks' ) - ); - } - - $response_code = wp_remote_retrieve_response_code( $client_auth_response ); - $response_body = wp_remote_retrieve_body( $client_auth_response ); - $response_data = json_decode( $response_body, true ); - - if ( 400 === $response_code || 401 === $response_code ) { - return new WP_Error( - 'salesforce_b2c_auth_error_client_credentials', - /* translators: %s: Technical error message from API containing failure reason */ - sprintf( __( 'Failed to retrieve access token from client credentials: "%s"', 'remote-data-blocks' ), $response_data['message'] ) - ); - } - - $access_token = $response_data['access_token']; - $access_token_expires_in = $response_data['expires_in']; - self::save_access_token( $access_token, $access_token_expires_in, $organization_id, $client_id ); - - $refresh_token = $response_data['refresh_token']; - $refresh_token_expires_in = $response_data['refresh_token_expires_in']; - self::save_refresh_token( $refresh_token, $refresh_token_expires_in, $organization_id, $client_id ); - - return $access_token; - } - - // Access token request using refresh token - - public static function get_token_using_refresh_token( - string $refresh_token, - string $client_id, - string $client_secret, - string $endpoint, - string $organization_id, - ): string|WP_Error { - $client_auth_url = sprintf( '%s/shopper/auth/v1/organizations/%s/oauth2/token', $endpoint, $organization_id ); - - // Even though we're using a refresh token, authentication is still required to receive a new secret - $client_credentials = base64_encode( sprintf( '%s:%s', $client_id, $client_secret ) ); - - $client_auth_response = wp_remote_post($client_auth_url, [ - 'body' => [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $refresh_token, - 'channel_id' => 'RefArch', - ], - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'Authorization' => 'Basic ' . $client_credentials, - ], - ]); - - $refresh_token_error = new WP_Error( - 'salesforce_b2c_auth_error_refresh_token', - __( 'Failed to refresh authorization with refresh token', 'remote-data-blocks' ) - ); - - if ( is_wp_error( $client_auth_response ) ) { - return $refresh_token_error; - } - - $response_code = wp_remote_retrieve_response_code( $client_auth_response ); - $response_body = wp_remote_retrieve_body( $client_auth_response ); - $response_data = json_decode( $response_body, true ); - - if ( 400 === $response_code ) { - return $refresh_token_error; - } - - $access_token = $response_data['access_token']; - $access_token_expires_in = $response_data['expires_in']; - self::save_access_token( $access_token, $access_token_expires_in, $organization_id, $client_id ); - - // No need to save the refresh token, as it stays the same until we perform a top-level authentication - - return $access_token; - } - - // Access token cache management - - private static function save_access_token( string $access_token, int $expires_in, string $organization_id, string $client_id ): void { - // Expires 10 seconds early as a buffer for request time and drift - $access_token_expires_in = $expires_in - 10; - - $access_token_data = [ - 'token' => $access_token, - 'expires_at' => time() + $access_token_expires_in, - ]; - - $access_token_cache_key = self::get_access_token_key( $organization_id, $client_id ); - - wp_cache_set( - $access_token_cache_key, - $access_token_data, - 'oauth-tokens', - // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined -- 'expires_in' defaults to 30 minutes for access tokens. - $access_token_expires_in, - ); - } - - private static function get_saved_access_token( string $organization_id, string $client_id ): ?string { - $access_token_cache_key = self::get_access_token_key( $organization_id, $client_id ); - - $saved_access_token = wp_cache_get( $access_token_cache_key, 'oauth-tokens' ); - - if ( false === $saved_access_token ) { - return null; - } - - $access_token = $saved_access_token['token']; - $expires_at = $saved_access_token['expires_at']; - - // Ensure the token is still valid - if ( time() >= $expires_at ) { - return null; - } - - return $access_token; - } - - private static function get_access_token_key( string $organization_id, string $client_id ): string { - $cache_key_suffix = hash( 'sha256', sprintf( '%s-%s', $organization_id, $client_id ) ); - return sprintf( 'salesforce_b2c_access_token_%s', $cache_key_suffix ); - } - - // Refresh token cache management - - private static function save_refresh_token( string $refresh_token, int $expires_in, string $organization_id, string $client_id ): void { - // Expires 10 seconds early as a buffer for request time and drift - $refresh_token_expires_in = $expires_in - 10; - - $refresh_token_data = [ - 'token' => $refresh_token, - 'expires_at' => time() + $refresh_token_expires_in, - ]; - - $refresh_token_cache_key = self::get_refresh_token_cache_key( $organization_id, $client_id ); - - wp_cache_set( - $refresh_token_cache_key, - $refresh_token_data, - 'oauth-tokens', - // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined -- 'expires_in' defaults to 30 days for refresh tokens. - $refresh_token_expires_in, - ); - } - - private static function get_saved_refresh_token( string $organization_id, string $client_id ): ?string { - $refresh_token_cache_key = self::get_refresh_token_cache_key( $organization_id, $client_id ); - - $saved_refresh_token = wp_cache_get( $refresh_token_cache_key, 'oauth-tokens' ); - - if ( false === $saved_refresh_token ) { - return null; - } - - $refresh_token = $saved_refresh_token['token']; - $expires_at = $saved_refresh_token['expires_at']; - - // Ensure the token is still valid - if ( time() >= $expires_at ) { - return null; - } - - return $refresh_token; - } - - private static function get_refresh_token_cache_key( string $organization_id, string $client_id ): string { - $cache_key_suffix = hash( 'sha256', sprintf( '%s-%s', $organization_id, $client_id ) ); - return sprintf( 'salesforce_b2c_refresh_token_%s', $cache_key_suffix ); - } -} diff --git a/inc/Integrations/SalesforceD2C/Auth/SalesforceD2CAuth.php b/inc/Integrations/SalesforceD2C/Auth/SalesforceD2CAuth.php new file mode 100644 index 00000000..b0357a5b --- /dev/null +++ b/inc/Integrations/SalesforceD2C/Auth/SalesforceD2CAuth.php @@ -0,0 +1,200 @@ + [ + 'Authorization' => 'Bearer ' . $token, + ], + ] ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== $response_code ) { + return new WP_Error( + 'salesforce_d2c_auth_error_webstores', + __( 'Failed to retrieve webstores', 'remote-data-blocks' ) + ); + } + + $response_body = wp_remote_retrieve_body( $response ); + $response_data = json_decode( $response_body, true ); + + $records = $response_data['records'] ?? []; + + return array_map( function ( $record ) { + return [ + 'id' => $record['Id'], + 'name' => $record['Name'], + ]; + }, $records ); + } + + /** + * Get a token using client credentials. + * + * @param string $client_id The client ID. + * @param string $client_secret The client secret. + * @param string $endpoint The endpoint prefix URL for the data source. + * @return WP_Error|string The token or an error. + */ + public static function get_token_using_client_credentials( + string $client_id, + string $client_secret, + string $endpoint, + ): WP_Error|string { + $client_auth_url = sprintf( '%s/services/oauth2/token', $endpoint ); + + $client_auth_url = add_query_arg( [ + 'grant_type' => 'client_credentials', + 'client_id' => $client_id, + 'client_secret' => $client_secret, + ], $client_auth_url ); + + $client_auth_response = wp_remote_post($client_auth_url, [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + ]); + + if ( is_wp_error( $client_auth_response ) ) { + return new WP_Error( + 'salesforce_d2c_auth_error_client_credentials', + __( 'Failed to retrieve access token from client credentials', 'remote-data-blocks' ) + ); + } + + $response_code = wp_remote_retrieve_response_code( $client_auth_response ); + $response_body = wp_remote_retrieve_body( $client_auth_response ); + $response_data = json_decode( $response_body, true ); + + if ( 200 !== $response_code ) { + return new WP_Error( + 'salesforce_d2c_auth_error_client_credentials', + /* translators: %s: Technical error message from API containing failure reason */ + sprintf( __( 'Failed to retrieve access token from client credentials: "%s"', 'remote-data-blocks' ), $response_data['message'] ) + ); + } + + $access_token = $response_data['access_token']; + + $client_introspect_url = sprintf( '%s/services/oauth2/introspect', $endpoint ); + $client_credentials = base64_encode( sprintf( '%s:%s', $client_id, $client_secret ) ); + + + $client_introspect_response = wp_remote_post($client_introspect_url, [ + 'body' => [ + 'token' => $access_token, + ], + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Authorization' => 'Basic ' . $client_credentials, + ], + ]); + + if ( is_wp_error( $client_introspect_response ) ) { + return new WP_Error( + 'salesforce_d2c_auth_error_client_credentials', + __( 'Failed to introspect token', 'remote-data-blocks' ) + ); + } + + $response_code = wp_remote_retrieve_response_code( $client_introspect_response ); + $response_body = wp_remote_retrieve_body( $client_introspect_response ); + $response_data = json_decode( $response_body, true ); + + if ( 400 === $response_code || 401 === $response_code ) { + return new WP_Error( + 'salesforce_d2c_auth_error_client_credentials', + /* translators: %s: Technical error message from API containing failure reason */ + sprintf( __( 'Failed to introspect token: "%s"', 'remote-data-blocks' ), $response_data['message'] ) + ); + } + + $expiry_time = $response_data['exp']; + + self::save_access_token( $access_token, $client_id, $expiry_time ); + + return $access_token; + } + + private static function save_access_token( string $access_token, string $client_id, int $expiry_time ): void { + // Get the time 10 seconds before the token expires. + // Note that, the expiry time is a unix timestamp and so we need to subtract the current time from it. + $access_token_expiry_time = $expiry_time - time() - 10; + + $access_token_data = [ + 'token' => $access_token, + ]; + + $access_token_cache_key = self::get_access_token_key( $client_id ); + + wp_cache_set( + $access_token_cache_key, + $access_token_data, + 'oauth-tokens', + // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined -- 'expires_in' defaults to 30 minutes for access tokens. + $access_token_expiry_time, + ); + } + + private static function get_saved_access_token( string $client_id ): ?string { + $access_token_cache_key = self::get_access_token_key( $client_id ); + + $saved_access_token = wp_cache_get( $access_token_cache_key, 'oauth-tokens' ); + + if ( false === $saved_access_token ) { + return null; + } + + return $saved_access_token['token'] ?? null; + } + + private static function get_access_token_key( string $client_id ): string { + $cache_key_suffix = hash( 'sha256', sprintf( '%s', $client_id ) ); + return sprintf( 'salesforce_d2c_access_token_%s', $cache_key_suffix ); + } +} diff --git a/inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php b/inc/Integrations/SalesforceD2C/SalesforceD2CDataSource.php similarity index 71% rename from inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php rename to inc/Integrations/SalesforceD2C/SalesforceD2CDataSource.php index 806f7ae9..ea75d2f1 100644 --- a/inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php +++ b/inc/Integrations/SalesforceD2C/SalesforceD2CDataSource.php @@ -1,6 +1,6 @@ Types::string(), 'client_secret' => Types::string(), 'enable_blocks' => Types::nullable( Types::boolean() ), - 'organization_id' => Types::string(), - 'shortcode' => Types::string(), + 'domain' => Types::string(), + 'store_id' => Types::string(), ] ); } protected static function map_service_config( array $service_config ): array { return [ 'display_name' => $service_config['display_name'], - 'endpoint' => sprintf( 'https://%s.api.commercecloud.salesforce.com', $service_config['shortcode'] ), + 'endpoint' => 'https://' . $service_config['domain'] . '.my.salesforce.com', 'image_url' => plugins_url( './assets/salesforce_commerce_cloud_logo.png', __FILE__ ), 'request_headers' => [ 'Content-Type' => 'application/json', diff --git a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php b/inc/Integrations/SalesforceD2C/SalesforceD2CIntegration.php similarity index 72% rename from inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php rename to inc/Integrations/SalesforceD2C/SalesforceD2CIntegration.php index dd4eafba..9b2949c5 100644 --- a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php +++ b/inc/Integrations/SalesforceD2C/SalesforceD2CIntegration.php @@ -1,40 +1,38 @@ REMOTE_DATA_BLOCKS_SALESFORCE_D2C_SERVICE, + 'enable_blocks' => true, + ] ); foreach ( $data_source_configs as $config ) { - $data_source = SalesforceB2CDataSource::from_array( $config ); - - if ( false === ( $config['service_config']['enable_blocks'] ?? true ) ) { - continue; - } - + $data_source = SalesforceD2CDataSource::from_array( $config ); + self::register_blocks_for_salesforce_data_source( $data_source ); } } - private static function get_queries( SalesforceB2CDataSource $data_source ): array { + private static function get_queries( SalesforceD2CDataSource $data_source ): array { $base_endpoint = $data_source->get_endpoint(); $service_config = $data_source->to_array()['service_config']; $get_request_headers = function () use ( $base_endpoint, $service_config ): array|WP_Error { - $access_token = SalesforceB2CAuth::generate_token( + $access_token = SalesforceD2CAuth::generate_token( $base_endpoint, - $service_config['organization_id'], $service_config['client_id'], $service_config['client_secret'] ); @@ -52,9 +50,9 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr 'data_source' => $data_source, 'endpoint' => function ( array $input_variables ) use ( $base_endpoint, $service_config ): string { return sprintf( - '%s/product/shopper-products/v1/organizations/%s/products/%s?siteId=RefArchGlobal', + '%s/services/data/v63.0/commerce/webstores/%s/products/%s', $base_endpoint, - $service_config['organization_id'], + $service_config['store_id'], $input_variables['product_id'] ); }, @@ -62,6 +60,7 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr 'product_id' => [ 'name' => 'Product ID', 'type' => 'id', + 'required' => true, ], ], 'output_schema' => [ @@ -74,27 +73,22 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr ], 'name' => [ 'name' => 'Name', - 'path' => '$.name', + 'path' => '$.fields.Name', 'type' => 'string', ], - 'longDescription' => [ - 'name' => 'Long Description', - 'path' => '$.longDescription', - 'type' => 'string', - ], - 'price' => [ - 'name' => 'Price', - 'path' => '$.price', + 'description' => [ + 'name' => 'Description', + 'path' => '$.fields.Description', 'type' => 'string', ], 'image_url' => [ 'name' => 'Image URL', - 'path' => '$.imageGroups[0].images[0].link', + 'path' => '$.defaultImage.url', 'type' => 'image_url', ], 'image_alt_text' => [ 'name' => 'Image Alt Text', - 'path' => '$.imageGroups[0].images[0].alt', + 'path' => '$.defaultImage.alternateText', 'type' => 'image_alt', ], ], @@ -105,39 +99,35 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr 'data_source' => $data_source, 'endpoint' => function ( array $input_variables ) use ( $base_endpoint, $service_config ): string { return sprintf( - '%s/search/shopper-search/v1/organizations/%s/product-search?siteId=RefArchGlobal&q=%s', + '%s/services/data/v63.0/commerce/webstores/%s/search/products?searchTerm=%s', $base_endpoint, - $service_config['organization_id'], + $service_config['store_id'], urlencode( $input_variables['search'] ) ); }, 'input_schema' => [ 'search' => [ + 'required' => true, 'type' => 'ui:search_input', ], ], 'output_schema' => [ - 'path' => '$.hits[*]', + 'path' => '$.productsPage.products[*]', 'is_collection' => true, 'type' => [ 'product_id' => [ - 'name' => 'product id', - 'path' => '$.productId', + 'name' => 'Product ID', + 'path' => '$.id', 'type' => 'id', ], 'name' => [ - 'name' => 'product name', - 'path' => '$.productName', - 'type' => 'string', - ], - 'price' => [ - 'name' => 'item price', - 'path' => '$.price', + 'name' => 'Name', + 'path' => '$.name', 'type' => 'string', ], 'image_url' => [ - 'name' => 'item image url', - 'path' => '$.image.link', + 'name' => 'Image URL', + 'path' => '$.defaultImage.url', 'type' => 'image_url', ], ], @@ -147,7 +137,7 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr ]; } - public static function register_blocks_for_salesforce_data_source( SalesforceB2CDataSource $data_source ): void { + public static function register_blocks_for_salesforce_data_source( SalesforceD2CDataSource $data_source ): void { $queries = self::get_queries( $data_source ); register_remote_data_block( @@ -190,7 +180,7 @@ public static function register_blocks_for_salesforce_data_source( SalesforceB2C } /** - * Get the block registration snippets for the Salesforce B2C integration. + * Get the block registration snippets for the Salesforce D2C integration. * * @param array $data_source_config The data source configuration. * @return array The block registration snippets. diff --git a/inc/Integrations/SalesforceB2C/assets/salesforce_commerce_cloud_logo.png b/inc/Integrations/SalesforceD2C/assets/salesforce_commerce_cloud_logo.png similarity index 100% rename from inc/Integrations/SalesforceB2C/assets/salesforce_commerce_cloud_logo.png rename to inc/Integrations/SalesforceD2C/assets/salesforce_commerce_cloud_logo.png diff --git a/inc/Integrations/SalesforceB2C/templates/block_registration.template b/inc/Integrations/SalesforceD2C/templates/block_registration.template similarity index 51% rename from inc/Integrations/SalesforceB2C/templates/block_registration.template rename to inc/Integrations/SalesforceD2C/templates/block_registration.template index 8045627a..c44dd145 100644 --- a/inc/Integrations/SalesforceB2C/templates/block_registration.template +++ b/inc/Integrations/SalesforceD2C/templates/block_registration.template @@ -1,31 +1,31 @@ REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE, + 'enable_blocks' => true, + ] ); foreach ( $data_source_configs as $config ) { $data_source = ShopifyDataSource::from_array( $config ); - if ( false === ( $config['service_config']['enable_blocks'] ?? true ) ) { - continue; - } - self::register_blocks_for_shopify_data_source( $data_source ); } } diff --git a/inc/Integrations/constants.php b/inc/Integrations/constants.php index 57e9352a..2424e9ae 100644 --- a/inc/Integrations/constants.php +++ b/inc/Integrations/constants.php @@ -7,7 +7,7 @@ define( 'REMOTE_DATA_BLOCKS_GITHUB_SERVICE', 'github' ); define( 'REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE', 'google-sheets' ); define( 'REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE', 'shopify' ); -define( 'REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE', 'salesforce-b2c' ); +define( 'REMOTE_DATA_BLOCKS_SALESFORCE_D2C_SERVICE', 'salesforce-d2c' ); define( 'REMOTE_DATA_BLOCKS_MOCK_SERVICE', 'mock' ); define( 'REMOTE_DATA_BLOCKS__SERVICES', [ @@ -15,7 +15,7 @@ REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE, REMOTE_DATA_BLOCKS_GITHUB_SERVICE, REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE, - REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE, + REMOTE_DATA_BLOCKS_SALESFORCE_D2C_SERVICE, REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE, ] ); @@ -24,7 +24,7 @@ REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE => \RemoteDataBlocks\Config\DataSource\HttpDataSource::class, REMOTE_DATA_BLOCKS_GITHUB_SERVICE => \RemoteDataBlocks\Integrations\GitHub\GitHubDataSource::class, REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE => \RemoteDataBlocks\Integrations\Google\Sheets\GoogleSheetsDataSource::class, - REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE => \RemoteDataBlocks\Integrations\SalesforceB2C\SalesforceB2CDataSource::class, + REMOTE_DATA_BLOCKS_SALESFORCE_D2C_SERVICE => \RemoteDataBlocks\Integrations\SalesforceD2C\SalesforceD2CDataSource::class, REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE => \RemoteDataBlocks\Integrations\Shopify\ShopifyDataSource::class, REMOTE_DATA_BLOCKS_MOCK_SERVICE => \RemoteDataBlocks\Tests\Mocks\MockDataSource::class, ]; diff --git a/inc/PluginSettings/PluginSettings.php b/inc/PluginSettings/PluginSettings.php index aafa424a..0d10d038 100644 --- a/inc/PluginSettings/PluginSettings.php +++ b/inc/PluginSettings/PluginSettings.php @@ -5,6 +5,8 @@ use RemoteDataBlocks\REST\DataSourceController; use RemoteDataBlocks\REST\AuthController; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; +use RemoteDataBlocks\Store\DataSource\DataSourceConfigManager; +use RemoteDataBlocks\Telemetry\DataSourceTelemetry; use function wp_get_environment_type; use function wp_is_development_mode; use function add_settings_error; @@ -37,6 +39,12 @@ public static function settings_page_content(): void { ', esc_html__( 'Loading…', 'remote-data-blocks' ) ); + + /** + * Track the view event. + */ + $configs = DataSourceConfigManager::get_all(); + DataSourceTelemetry::track_view( $configs ); } public static function init_rest_routes(): void { diff --git a/inc/REST/AuthController.php b/inc/REST/AuthController.php index 21f8fb83..d5d0bb0c 100644 --- a/inc/REST/AuthController.php +++ b/inc/REST/AuthController.php @@ -3,6 +3,7 @@ namespace RemoteDataBlocks\REST; use RemoteDataBlocks\Integrations\Google\Auth\GoogleAuth; +use RemoteDataBlocks\Integrations\SalesforceD2C\Auth\SalesforceD2CAuth; use WP_REST_Controller; use WP_REST_Request; use WP_REST_Response; @@ -34,7 +35,21 @@ public function register_routes(): void { [ 'methods' => 'POST', 'callback' => [ $this, 'get_google_auth_token' ], - 'permission_callback' => [ $this, 'get_google_auth_token_permissions_check' ], + 'permission_callback' => [ $this, 'permissions_check' ], + ] + ); + + /** + * API to get Salesforce D2C Stores using the client_credentials grant type + * This is also meant to test the credentials provided by the user. + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/salesforce-d2c/stores', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_salesforce_d2c_stores' ], + 'permission_callback' => [ $this, 'permissions_check' ], ] ); } @@ -68,11 +83,52 @@ public function get_google_auth_token( WP_REST_Request $request ): WP_REST_Respo ); } + public function get_salesforce_d2c_stores( WP_REST_Request $request ): WP_REST_Response|WP_Error { + $params = $request->get_json_params(); + $client_id = $params['clientId'] ?? null; + $client_secret = $params['clientSecret'] ?? null; + $domain = $params['domain'] ?? null; + + if ( ! $client_id || ! $client_secret || ! $domain ) { + return new \WP_Error( + 'missing_parameters', + __( 'Client ID, client secret and domain are required.', 'remote-data-blocks' ), + array( 'status' => 400 ) + ); + } + + $endpoint = 'https://' . $domain . '.my.salesforce.com'; + + $token = SalesforceD2CAuth::generate_token( $endpoint, $client_id, $client_secret ); + if ( is_wp_error( $token ) ) { + return new \WP_Error( + 'failed-to-generate-token', + __( 'Failed to generate token', 'remote-data-blocks' ), + array( 'status' => 400 ) + ); + } + + $webstores = SalesforceD2CAuth::get_webstores( $endpoint, $token ); + if ( is_wp_error( $webstores ) ) { + return new \WP_Error( + 'failed-to-retrieve-webstores', + __( 'Failed to retrieve webstores', 'remote-data-blocks' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( + [ + 'webstores' => $webstores, + ] + ); + } + /** * These all require manage_options for now, but we can adjust as needed. * Taken from /inc/REST/DataSourceController.php */ - public function get_google_auth_token_permissions_check(): bool { + public function permissions_check(): bool { return current_user_can( 'manage_options' ); } } diff --git a/inc/REST/DataSourceController.php b/inc/REST/DataSourceController.php index 7d331583..9558bfc8 100644 --- a/inc/REST/DataSourceController.php +++ b/inc/REST/DataSourceController.php @@ -2,9 +2,8 @@ namespace RemoteDataBlocks\REST; -use RemoteDataBlocks\Analytics\TracksAnalytics; -use RemoteDataBlocks\Editor\BlockManagement\ConfigStore; -use RemoteDataBlocks\WpdbStorage\DataSourceCrud; +use RemoteDataBlocks\Telemetry\DataSourceTelemetry; +use RemoteDataBlocks\Store\DataSource\DataSourceConfigManager; use RemoteDataBlocks\Snippet\Snippet; use WP_REST_Controller; use WP_REST_Request; @@ -127,7 +126,7 @@ public function register_routes(): void { '/' . $this->rest_base . '/(?P[a-zA-Z0-9,-]+)', [ 'methods' => 'DELETE', - 'callback' => [ $this, 'delete_multiple_items' ], + 'callback' => [ $this, 'delete_multiple_items' ], 'permission_callback' => [ $this, 'delete_item_permissions_check' ], 'args' => [ 'uuids' => [ @@ -147,12 +146,9 @@ public function register_routes(): void { */ public function create_item( mixed $request ): WP_REST_Response|WP_Error { $data_source_properties = $request->get_json_params(); - $item = DataSourceCrud::create_config( $data_source_properties ); + $item = DataSourceConfigManager::create( $data_source_properties ); - TracksAnalytics::record_event( 'remotedatablocks_data_source_interaction', array_merge( [ - 'data_source_type' => $data_source_properties['service'], - 'action' => 'add', - ], $this->get_data_source_interaction_track_props( $data_source_properties ) ) ); + DataSourceTelemetry::track_add( $data_source_properties ); return rest_ensure_response( $item ); } @@ -164,40 +160,7 @@ public function create_item( mixed $request ): WP_REST_Response|WP_Error { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( mixed $request ): WP_REST_Response|WP_Error { - $code_configured_data_sources = ConfigStore::get_data_sources_as_array(); - $ui_configured_data_sources = DataSourceCrud::get_configs(); - - /** - * Quick and dirty de-duplication of data sources. If the data source does - * not have a UUID (because it is registered in code), we generate an - * identifier based on the display name and service name. - * - * UI configured data sources take precedence over code configured ones - * here due to the ordering of the two arrays passed to array_reduce. - */ - $data_sources = array_values( array_reduce( - array_merge( $code_configured_data_sources, $ui_configured_data_sources ), - function ( $acc, $item ) { - $identifier = $item['uuid'] ?? md5( sprintf( '%s_%s', $item['service_config']['display_name'], $item['service'] ) ); - $acc[ $identifier ] = $item; - return $acc; - }, - [] - ) ); - - // Tracks Analytics. Only once per day to reduce noise. - $track_transient_key = 'remotedatablocks_view_data_sources_tracked'; - if ( ! get_transient( $track_transient_key ) ) { - $code_configured_data_sources_count = count( $code_configured_data_sources ); - $ui_configured_data_sources_count = count( $ui_configured_data_sources ); - - TracksAnalytics::record_event( 'remotedatablocks_view_data_sources', [ - 'total_data_sources_count' => $code_configured_data_sources_count + $ui_configured_data_sources_count, - 'code_configured_data_sources_count' => $code_configured_data_sources_count, - 'ui_configured_data_sources_count' => $ui_configured_data_sources_count, - ] ); - set_transient( $track_transient_key, true, DAY_IN_SECONDS ); - } + $data_sources = DataSourceConfigManager::get_all(); return rest_ensure_response( $data_sources ); } @@ -209,7 +172,7 @@ function ( $acc, $item ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( mixed $request ): WP_REST_Response|WP_Error { - $response = DataSourceCrud::get_config_by_uuid( $request->get_param( 'uuid' ) ); + $response = DataSourceConfigManager::get( $request->get_param( 'uuid' ) ); return rest_ensure_response( $response ); } @@ -226,16 +189,13 @@ public function get_snippets( mixed $request ): WP_REST_Response|WP_Error { */ public function update_item( mixed $request ): WP_REST_Response|WP_Error { $data_source_properties = $request->get_json_params(); - $item = DataSourceCrud::update_config_by_uuid( $request->get_param( 'uuid' ), $data_source_properties ); + $item = DataSourceConfigManager::update( $request->get_param( 'uuid' ), $data_source_properties ); if ( is_wp_error( $item ) ) { return $item; // Return WP_Error if update fails } - TracksAnalytics::record_event( 'remotedatablocks_data_source_interaction', array_merge( [ - 'data_source_type' => $item['service'], - 'action' => 'update', - ], $this->get_data_source_interaction_track_props( $item ) ) ); + DataSourceTelemetry::track_update( $data_source_properties ); return rest_ensure_response( $item ); } @@ -248,13 +208,9 @@ public function update_item( mixed $request ): WP_REST_Response|WP_Error { */ public function delete_item( mixed $request ): WP_REST_Response|WP_Error { $data_source_properties = $request->get_json_params(); - $result = DataSourceCrud::delete_config_by_uuid( $request->get_param( 'uuid' ) ); + $result = DataSourceConfigManager::delete( $request->get_param( 'uuid' ) ); - // Tracks Analytics. - TracksAnalytics::record_event( 'remotedatablocks_data_source_interaction', [ - 'data_source_type' => $data_source_properties['service'], - 'action' => 'delete', - ] ); + DataSourceTelemetry::track_delete( $data_source_properties ); return rest_ensure_response( $result ); } @@ -278,7 +234,7 @@ public function delete_multiple_items( WP_REST_Request $request ): WP_REST_Respo $failed = []; foreach ( $uuids as $uuid ) { - $result = DataSourceCrud::delete_config_by_uuid( $uuid ); + $result = DataSourceConfigManager::delete( $uuid ); if ( is_wp_error( $result ) ) { $failed[] = [ 'uuid' => $uuid, @@ -301,7 +257,6 @@ public function delete_multiple_items( WP_REST_Request $request ): WP_REST_Respo ]); } - // These all require manage_options for now, but we can adjust as needed public function get_item_permissions_check( mixed $request ): bool|WP_Error { @@ -323,16 +278,4 @@ public function update_item_permissions_check( mixed $request ): bool|WP_Error { public function delete_item_permissions_check( mixed $request ): bool|WP_Error { return current_user_can( 'manage_options' ); } - - private function get_data_source_interaction_track_props( array $data_source_properties ): array { - $props = []; - - if ( 'generic-http' === $data_source_properties['service'] ) { - $auth = $data_source_properties['service_config']['auth'] ?? []; - $props['authentication_type'] = $auth['type'] ?? ''; - $props['api_key_location'] = $auth['addTo'] ?? ''; - } - - return $props; - } } diff --git a/inc/Snippet/Snippet.php b/inc/Snippet/Snippet.php index 12139d04..c7e773b4 100644 --- a/inc/Snippet/Snippet.php +++ b/inc/Snippet/Snippet.php @@ -7,7 +7,7 @@ use RemoteDataBlocks\Integrations\Airtable\AirtableIntegration; use RemoteDataBlocks\Integrations\Google\Sheets\GoogleSheetsIntegration; use RemoteDataBlocks\Integrations\Shopify\ShopifyIntegration; -use RemoteDataBlocks\Integrations\SalesforceB2C\SalesforceB2CIntegration; +use RemoteDataBlocks\Integrations\SalesforceD2C\SalesforceD2CIntegration; use WP_Error; class Snippet implements JsonSerializable { @@ -40,17 +40,17 @@ public static function generate_snippets( string $uuid ): array|WP_Error { $service = $data_source_config['service']; switch ( $service ) { - case 'shopify': + case REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE: $snippets = ShopifyIntegration::get_block_registration_snippets( $data_source_config ); break; - case 'airtable': + case REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE: $snippets = AirtableIntegration::get_block_registration_snippets( $data_source_config ); break; - case 'google-sheets': + case REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE: $snippets = GoogleSheetsIntegration::get_block_registration_snippets( $data_source_config ); break; - case 'salesforce-b2c': - $snippets = SalesforceB2CIntegration::get_block_registration_snippets( $data_source_config ); + case REMOTE_DATA_BLOCKS_SALESFORCE_D2C_SERVICE: + $snippets = SalesforceD2CIntegration::get_block_registration_snippets( $data_source_config ); break; default: return new WP_Error( 'invalid_service', __( 'Invalid service', 'remote-data-blocks' ) ); diff --git a/inc/Store/DataSource/ConstantConfigStore.php b/inc/Store/DataSource/ConstantConfigStore.php new file mode 100644 index 00000000..531c72df --- /dev/null +++ b/inc/Store/DataSource/ConstantConfigStore.php @@ -0,0 +1,97 @@ + + * }> + */ + public static function get_configs(): array { + if ( ! self::is_available() ) { + return []; + } + + $configs = constant( self::CONFIG_CONSTANT_NAME ); + $valid_configs = []; + + foreach ( $configs as $config ) { + $validation_result = self::validate_config( $config ); + if ( ! is_wp_error( $validation_result ) ) { + $valid_configs[] = $config; + } else { + LoggerManager::instance()->error( + sprintf( + 'Invalid data source config found (uuid: %s): %s', + $config['uuid'] ?? 'unknown', + $validation_result->get_error_message() + ) + ); + } + } + + return $valid_configs; + } + + public static function get_config_by_uuid( string $uuid ): array|WP_Error { + $configs = constant( self::CONFIG_CONSTANT_NAME ); + $found = array_filter( $configs, function ( $config ) use ( $uuid ) { + return $config['uuid'] === $uuid; + } ); + + if ( empty( $found ) ) { + return new WP_Error( + 'data_source_not_found', + __( 'Data source not found', 'remote-data-blocks' ), + [ 'status' => 404 ] + ); + } + + $config = reset( $found ); + $validation_result = self::validate_config( $config ); + + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + + return $config; + } + + private static function validate_config( array $config ): DataSourceInterface|WP_Error { + $data_source_class = REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP[ $config['service'] ] ?? null; + if ( null === $data_source_class ) { + return new WP_Error( + 'unsupported_data_source', + __( 'Unsupported data source service', 'remote-data-blocks' ) + ); + } + + return $data_source_class::from_array( $config ); + } + + /** + * Check if constant configuration is available and valid. + * + * @return bool Whether the constant configuration is available and valid. + */ + private static function is_available(): bool { + if ( ! defined( self::CONFIG_CONSTANT_NAME ) ) { + return false; + } + + $value = constant( self::CONFIG_CONSTANT_NAME ); + return is_array( $value ); + } +} diff --git a/inc/Store/DataSource/DataSourceConfigManager.php b/inc/Store/DataSource/DataSourceConfigManager.php new file mode 100644 index 00000000..32527a13 --- /dev/null +++ b/inc/Store/DataSource/DataSourceConfigManager.php @@ -0,0 +1,264 @@ + self::CONFIG_SOURCE_STORAGE ] ); + }, + DataSourceCrud::get_configs() + ); + } + + private static function get_all_from_constant(): array { + return array_map( + function ( array $config ) { + return array_merge( $config, [ 'config_source' => self::CONFIG_SOURCE_CONSTANT ] ); + }, + ConstantConfigStore::get_configs() + ); + } + + private static function get_all_from_code(): array { + return array_map( + function ( array $config ) { + return array_merge( $config, [ 'config_source' => self::CONFIG_SOURCE_CODE ] ); + }, + ConfigStore::get_data_sources_as_array() + ); + } + + /** + * Quick and dirty de-duplication of data sources. If the data source does + * not have a UUID (because it is registered in code), we generate an + * identifier based on the display name and service name. + */ + private static function de_duplicate_configs( array $configs ): array { + return array_values( array_reduce( + $configs, + function ( array $acc, array $item ) { + $identifier = $item['uuid'] ?? md5( + sprintf( '%s_%s', $item['service_config']['display_name'], $item['service'] ) + ); + $acc[ $identifier ] = $item; + return $acc; + }, + [] + ) ); + } + + /** + * Get all data sources from all origins with optional filters. + * + * Supported filters: + * - service: Filter by service name (e.g. 'airtable', 'google-sheets', 'shopify') + * - enable_blocks: Filter by blocks enabled status (false matches with null/false and true matches with true) + * + * Passing an unsupported filter key will return an error. + * + * @param array{ + * service?: string, + * enable_blocks?: bool + * } $filters Optional filters to apply to the results. + * @return array, + * config_source: string, + * __metadata?: array{ + * created_at: string, + * updated_at: string + * } + * }> + */ + public static function get_all( array $filters = [] ): array|WP_Error { + $code_configured = self::get_all_from_code(); + $constant_configured = self::get_all_from_constant(); + $storage_configured = self::get_all_from_storage(); + + /** + * De-duplicate configs. + * + * Precedence (lowest to highest): + * - Code-configured data sources + * - Constant-configured data sources + * - Storage-configured data sources + */ + $configs = self::de_duplicate_configs( + array_merge( $code_configured, $constant_configured, $storage_configured ) + ); + + return self::apply_config_array_filters( $configs, $filters ); + } + + /** + * Apply filters to an array of configs. + * + * @param array $configs The configs to filter. + * @param array{ + * service?: string, + * enable_blocks?: bool + * } $filters The filters to apply. + * @return array|WP_Error The filtered configs or WP_Error if invalid filter. + */ + private static function apply_config_array_filters( array $configs, array $filters ): array|WP_Error { + if ( empty( $filters ) ) { + return $configs; + } + + /** + * Validate all filter keys. + */ + foreach ( array_keys( $filters ) as $key ) { + /** @var non-empty-string $key */ + if ( ! in_array( $key, [ 'service', 'enable_blocks' ], true ) ) { + return new WP_Error( + 'invalid_filter', + sprintf( 'Invalid filter key: %s', (string) $key ), + [ 'status' => 400 ] + ); + } + } + + return array_filter( + $configs, + function ( array $config ) use ( $filters ): bool { + foreach ( $filters as $key => $value ) { + /** @var string $key Either 'service' or 'enable_blocks' */ + $passes_filter = match ( $key ) { + 'service' => $config['service'] === $value, + 'enable_blocks' => ( $config['service_config']['enable_blocks'] ?? false ) === $value, + }; + + if ( ! $passes_filter ) { + return false; + } + } + return true; + } + ); + } + + /** + * Get a data source by its UUID. + * + * @param string $uuid The UUID of the data source to get. + * @return array{ + * uuid: string, + * service: string, + * service_config: array, + * config_source: string, + * __metadata?: array{ + * created_at: string, + * updated_at: string + * } + * }|WP_Error + */ + public static function get( string $uuid ): array|WP_Error { + $from_constant = ConstantConfigStore::get_config_by_uuid( $uuid ); + if ( ! is_wp_error( $from_constant ) ) { + return array_merge( + $from_constant, + [ 'config_source' => self::CONFIG_SOURCE_CONSTANT ] + ); + } + + $from_storage = DataSourceCrud::get_config_by_uuid( $uuid ); + if ( ! is_wp_error( $from_storage ) ) { + return array_merge( + $from_storage, + [ 'config_source' => self::CONFIG_SOURCE_STORAGE ] + ); + } + + return new WP_Error( + 'data_source_not_found', + __( 'Data source not found', 'remote-data-blocks' ), + [ 'status' => 404 ] + ); + } + + /** + * Create a new data source. + * + * @param array $config The configuration for the new data source. + * @return array{ + * uuid: string, + * service: string, + * service_config: array, + * config_source: string, + * __metadata: array{ + * created_at: string, + * updated_at: string + * } + * }|WP_Error + */ + public static function create( array $config ): array|WP_Error { + $result = DataSourceCrud::create_config( $config ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array_merge( $result, [ 'config_source' => self::CONFIG_SOURCE_STORAGE ] ); + } + + /** + * Update a data source. + * + * @param string $uuid The UUID of the data source to update. + * @param array $config The new configuration for the data source. + * @return array{ + * uuid: string, + * service: string, + * service_config: array, + * config_source: string, + * __metadata: array{ + * created_at: string, + * updated_at: string + * } + * }|WP_Error + */ + public static function update( string $uuid, array $config ): array|WP_Error { + if ( + isset( $config['config_source'] ) && + ! in_array( $config['config_source'], self::MUTABLE_CONFIG_SOURCES, true ) + ) { + /** + * Only storage-configured data sources are mutable. + */ + return new WP_Error( + 'cannot_update_config', + __( 'Cannot update a data source with this config_source', 'remote-data-blocks' ), + [ 'status' => 400 ] + ); + } + + $result = DataSourceCrud::update_config_by_uuid( $uuid, $config ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array_merge( $result, [ 'config_source' => self::CONFIG_SOURCE_STORAGE ] ); + } + + /** + * Delete a data source. + * + * @param string $uuid The UUID of the data source to delete. + * @return true|WP_Error True on success, WP_Error on failure. + */ + public static function delete( string $uuid ): bool|WP_Error { + return DataSourceCrud::delete_config_by_uuid( $uuid ); + } +} diff --git a/inc/Telemetry/DataSourceTelemetry.php b/inc/Telemetry/DataSourceTelemetry.php new file mode 100644 index 00000000..c6dd711f --- /dev/null +++ b/inc/Telemetry/DataSourceTelemetry.php @@ -0,0 +1,71 @@ + $config['service'], + 'action' => $action, + ], self::get_interaction_track_props( $config ) ) ); + } + + public static function track_add( array $config ): void { + self::track_interaction( $config, 'add' ); + } + + public static function track_update( array $config ): void { + self::track_interaction( $config, 'update' ); + } + + public static function track_delete( array $config ): void { + self::track_interaction( $config, 'delete' ); + } + + public static function track_view( array $configs ): void { + $code_configured_count = count( array_filter( + $configs, + function ( $config ) { + return DataSourceConfigManager::CONFIG_SOURCE_CODE === $config['config_source']; + } + ) ); + $storage_configured_count = count( array_filter( + $configs, + function ( $config ) { + return DataSourceConfigManager::CONFIG_SOURCE_STORAGE === $config['config_source']; + } + ) ); + $constant_configured_count = count( array_filter( + $configs, + function ( $config ) { + return DataSourceConfigManager::CONFIG_SOURCE_CONSTANT === $config['config_source']; + } + ) ); + + TracksTelemetry::record_event( self::DATA_SOURCE_VIEW_EVENT_NAME, [ + 'total_data_sources_count' => count( $configs ), + 'code_configured_data_sources_count' => $code_configured_count, + 'ui_configured_data_sources_count' => $storage_configured_count, + 'constant_configured_data_sources_count' => $constant_configured_count, + ] ); + } +} diff --git a/inc/Analytics/EnvironmentConfig.php b/inc/Telemetry/EnvironmentConfig.php similarity index 91% rename from inc/Analytics/EnvironmentConfig.php rename to inc/Telemetry/EnvironmentConfig.php index ab1d1040..21712c3c 100644 --- a/inc/Analytics/EnvironmentConfig.php +++ b/inc/Telemetry/EnvironmentConfig.php @@ -1,6 +1,6 @@ 'activate' ] ); + self::record_event( 'plugin_toggle', [ 'action' => 'activate' ] ); } /** @@ -82,7 +84,7 @@ public static function track_plugin_deactivation( string $plugin_path ): void { return; } - self::record_event( 'remotedatablocks_plugin_toggle', [ 'action' => 'deactivate' ] ); + self::record_event( 'plugin_toggle', [ 'action' => 'deactivate' ] ); } /** @@ -125,7 +127,7 @@ public static function track_remote_data_blocks_usage( int $post_id, object $pos $track_props['remote_data_blocks_total_count'] = ( $track_props['remote_data_blocks_total_count'] ?? 0 ) + 1; } - self::record_event( 'remotedatablocks_blocks_usage_stats', $track_props ); + self::record_event( 'blocks_usage_stats', $track_props ); } /** @@ -141,6 +143,8 @@ public static function record_event( string $event_name, array $props ): bool { return false; } + $event_name = self::TRACKS_EVENT_PREFIX . $event_name; + self::$instance->record_event( $event_name, $props ); return true; diff --git a/inc/Validation/ConfigSchemas.php b/inc/Validation/ConfigSchemas.php index ab0b10c1..45c5b2a4 100644 --- a/inc/Validation/ConfigSchemas.php +++ b/inc/Validation/ConfigSchemas.php @@ -227,6 +227,7 @@ private static function generate_http_query_config_schema(): array { 'ui:pagination_cursor_next', 'ui:pagination_cursor_previous', ), + 'required' => Types::nullable( Types::boolean() ), ] ), ) ), diff --git a/package-lock.json b/package-lock.json index 9e4f748d..9f7d8a29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@automattic/calypso-analytics": "^1.1.3", - "@wordpress/dataviews": "^4.13.0", + "@wordpress/dataviews": "^4.14.0", "react-syntax-highlighter": "^15.6.1" }, "devDependencies": { @@ -21,35 +21,35 @@ "@testing-library/react": "16.2.0", "@types/react-syntax-highlighter": "^15.5.13", "@types/wordpress__block-editor": "11.5.16", - "@types/wordpress__blocks": "12.5.16", + "@types/wordpress__blocks": "12.5.17", "@vitest/coverage-v8": "3.0.4", - "@wordpress/api-fetch": "7.17.0", - "@wordpress/block-editor": "14.12.0", - "@wordpress/blocks": "14.6.0", - "@wordpress/components": "29.3.0", - "@wordpress/compose": "7.17.0", - "@wordpress/core-data": "7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.17.0", - "@wordpress/dom-ready": "4.17.0", - "@wordpress/e2e-test-utils-playwright": "^1.17.0", - "@wordpress/editor": "14.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/env": "10.17.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/interactivity": "6.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/plugins": "7.17.0", - "@wordpress/primitives": "4.17.0", - "@wordpress/rich-text": "7.17.0", + "@wordpress/api-fetch": "7.18.0", + "@wordpress/block-editor": "14.13.0", + "@wordpress/blocks": "14.7.0", + "@wordpress/components": "29.4.0", + "@wordpress/compose": "7.18.0", + "@wordpress/core-data": "7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.18.0", + "@wordpress/dom-ready": "4.18.0", + "@wordpress/e2e-test-utils-playwright": "^1.18.0", + "@wordpress/editor": "14.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/env": "10.18.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/interactivity": "6.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/plugins": "7.18.0", + "@wordpress/primitives": "4.18.0", + "@wordpress/rich-text": "7.18.0", "@wordpress/scripts": "30.1.0", - "@wordpress/server-side-render": "5.17.0", - "@wordpress/url": "4.17.0", + "@wordpress/server-side-render": "5.18.0", + "@wordpress/url": "4.18.0", "eslint": "8.57.1", "fork-ts-checker-webpack-plugin": "9.0.2", - "happy-dom": "16.8.1", + "happy-dom": "17.1.0", "husky": "9.1.7", "lint-staged": "15.4.3", "prettier": "npm:wp-prettier@2.8.5", @@ -6357,16 +6357,17 @@ } }, "node_modules/@types/wordpress__blocks": { - "version": "12.5.16", - "resolved": "https://registry.npmjs.org/@types/wordpress__blocks/-/wordpress__blocks-12.5.16.tgz", - "integrity": "sha512-WA6lsGY/DBR918wxWClG0rhg1o0qByYjfRzsXkQkKbbKb5RoCZV8ZTV5NyUHxaJUSI+PGjAX1DThQJESLWJkKQ==", + "version": "12.5.17", + "resolved": "https://registry.npmjs.org/@types/wordpress__blocks/-/wordpress__blocks-12.5.17.tgz", + "integrity": "sha512-4IyMaHai+g4x3ItG0pVhpct9bpksUDjSgkHSbk7BYGdzYMIJrEPBJcxkIC2og2OTEdJqpSTb6vYiEFdLM/ADcQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/react": "^18", - "@types/wordpress__shortcode": "*", "@wordpress/components": "^27.2.0", "@wordpress/data": "^9.13.0", - "@wordpress/element": "^5.0.0" + "@wordpress/element": "^5.0.0", + "@wordpress/shortcode": "^4.14.0" } }, "node_modules/@types/wordpress__blocks/node_modules/@ariakit/core": { @@ -6796,12 +6797,6 @@ "node": ">=12" } }, - "node_modules/@types/wordpress__shortcode": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@types/wordpress__shortcode/-/wordpress__shortcode-2.3.6.tgz", - "integrity": "sha512-H8BVov7QWyLLoxCaI9QyZVC4zTi1mFkZ+eEKiXBCFlaJ0XV8UVfQk+cAetqD5mWOeWv2d4b8uzzyn0TTQ/ep2g==", - "dev": true - }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -7502,14 +7497,14 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.17.0.tgz", - "integrity": "sha512-TCQ/PGC0Me3yzPUrmY2FpECl7GUcUcx6kVGUugmlMxNwxeZRYUOEMxsHGm07iKV5l7zbi3y5c/i5bbYwJfXA4g==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.18.0.tgz", + "integrity": "sha512-aF/E6VtGHRUuNX4rJ8dJMNHKrYKsKoYVXI8n1oYg+Tbsq4fye5UJ3kLEGWLYoA8Yvvgsi2bvJ44IpBB4OhNCFg==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/dom-ready": "4.17.0", - "@wordpress/i18n": "5.17.0" + "@wordpress/dom-ready": "4.18.0", + "@wordpress/i18n": "5.18.0" }, "engines": { "node": ">=18.12.0", @@ -7517,15 +7512,15 @@ } }, "node_modules/@wordpress/api-fetch": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.17.0.tgz", - "integrity": "sha512-L3iT/K41R6KResTy/7EOsTD+KKO20U3B4lPz/jQMRNgFdq4MOxtalEMjrRoj1mG+qiYGYdvGmpSgOzSx9o3eRg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.18.0.tgz", + "integrity": "sha512-mhzSIDRon8OWa1PLqo1gW35mfIkmjSXHteFJAmGHx7j3b/2+pa3THia8o2xYFXx+A0KWm0A3VOCuNUPp9q7aPg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "5.17.0", - "@wordpress/url": "4.17.0" + "@wordpress/i18n": "5.18.0", + "@wordpress/url": "4.18.0" }, "engines": { "node": ">=18.12.0", @@ -7533,9 +7528,9 @@ } }, "node_modules/@wordpress/autop": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.17.0.tgz", - "integrity": "sha512-6O9Eo/S02OHIa4GflfcWHANHpuy5/SifaWiprWYTrhIt6L6DyVxr1AErSWfDXIrkNNVXuhhykYDHAtApKqpqsQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.18.0.tgz", + "integrity": "sha512-u+Gb8k41Af1TBd2967LSMN634acHyC2PlGCE5VpzrPInApb6REOwhf2nLh1ZUO1CC1f03pIyhqohhmNI/DwWrQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7580,9 +7575,9 @@ } }, "node_modules/@wordpress/blob": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.17.0.tgz", - "integrity": "sha512-qH0Q48clM+UTdTMWUsCyyAuy4J+koNGLz4oXyJZCrUvUQ31Hpj6VwQulM2lSXYQyzOWJEKf3deHM47Uz1JYhhg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.18.0.tgz", + "integrity": "sha512-L9mcc/4aDgRuTSQmEfbwkVKWzTyvyHAw2FT6fqTiTzSOjQRtgndPMMEPhda29scEE79Hbx5+mZdoW41J32bnOQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7594,9 +7589,9 @@ } }, "node_modules/@wordpress/block-editor": { - "version": "14.12.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-14.12.0.tgz", - "integrity": "sha512-i8tUlPiRgLqUFVnAHDjS7MNHZMFDYMkm5gR2xsNryzhsvoAndUYJiktftbXNaQVki/EMoDf1zHicHZ2g2AQy5Q==", + "version": "14.13.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-14.13.0.tgz", + "integrity": "sha512-KRUWTHGM5jTzFrelG8oqHle5TiU5m4zB22d70mKMc2i4GqJKJ4Hd0nRJ/Z7vJmSCvR5UKV7f85kV4jhg1YNWlw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7604,38 +7599,38 @@ "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@react-spring/web": "^9.4.5", - "@wordpress/a11y": "^4.17.0", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/blob": "^4.17.0", - "@wordpress/block-serialization-default-parser": "^5.17.0", - "@wordpress/blocks": "14.6.0", - "@wordpress/commands": "^1.17.0", - "@wordpress/components": "29.3.0", - "@wordpress/compose": "7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/date": "^5.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/dom": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/escape-html": "^3.17.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", - "@wordpress/keyboard-shortcuts": "^5.17.0", - "@wordpress/keycodes": "^4.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/preferences": "^4.17.0", - "@wordpress/priority-queue": "^3.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/rich-text": "7.17.0", - "@wordpress/style-engine": "^2.17.0", - "@wordpress/token-list": "^3.17.0", - "@wordpress/upload-media": "^0.2.0", - "@wordpress/url": "4.17.0", - "@wordpress/warning": "^3.17.0", - "@wordpress/wordcount": "^4.17.0", + "@wordpress/a11y": "^4.18.0", + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/blob": "^4.18.0", + "@wordpress/block-serialization-default-parser": "^5.18.0", + "@wordpress/blocks": "14.7.0", + "@wordpress/commands": "^1.18.0", + "@wordpress/components": "29.4.0", + "@wordpress/compose": "7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/date": "^5.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/dom": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/escape-html": "^3.18.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", + "@wordpress/keyboard-shortcuts": "^5.18.0", + "@wordpress/keycodes": "^4.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/preferences": "^4.18.0", + "@wordpress/priority-queue": "^3.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/rich-text": "7.18.0", + "@wordpress/style-engine": "^2.18.0", + "@wordpress/token-list": "^3.18.0", + "@wordpress/upload-media": "^0.3.0", + "@wordpress/url": "4.18.0", + "@wordpress/warning": "^3.18.0", + "@wordpress/wordcount": "^4.18.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -7661,14 +7656,14 @@ } }, "node_modules/@wordpress/block-editor/node_modules/@wordpress/keycodes": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.17.0.tgz", - "integrity": "sha512-6aZ28uoCmzjXONpRVtDPjevkw834fhIRBnn2KQdzENMnPiQCNbiG71mPNxkTw1yRHRRT5ptHvOe49ztWm9KMcA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.18.0.tgz", + "integrity": "sha512-96dkj/z0eDsA9kfM+vMsaY9MLUZGO9zJMlklkyaGltsVqrO/WhlNPEui7/JerJ+YUL0oncdUaGiwcj5pKs09cA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "5.17.0" + "@wordpress/i18n": "5.18.0" }, "engines": { "node": ">=18.12.0", @@ -7676,9 +7671,9 @@ } }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.17.0.tgz", - "integrity": "sha512-4oVgm6f/kRqersuTH1SS85x89P4foPAo2xwjoXvHdjy1Rp0UQ86uxyKn0j0A6k7uQEXc5BJeUevk/Z1AT1Z9bQ==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.18.0.tgz", + "integrity": "sha512-ZviHll60ii6JDEhDV6EAuPd/utK2RihS2r3YCLFaQBaDrZ2xRMsL8Gaz8otCea/OiNxGzHW9A/O+rL6lwogLDw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7690,28 +7685,28 @@ } }, "node_modules/@wordpress/blocks": { - "version": "14.6.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-14.6.0.tgz", - "integrity": "sha512-9FkjXHRTXIaOU7BJfoeRUe1snh+5H8rypOTJoDpiMCoXMfGKyBVpacRMzbltQiK7SrzmHbzst4EuxHoK7a/TVw==", + "version": "14.7.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-14.7.0.tgz", + "integrity": "sha512-MQm52Mbr6mjOk949qKbA7kPwwpn80usFSYYy7THKZKXwk1DvxzrUUMWrO8OsgSp2TE9lnpGVVL3/O8PrzjWduA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/autop": "^4.17.0", - "@wordpress/blob": "^4.17.0", - "@wordpress/block-serialization-default-parser": "^5.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/dom": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/rich-text": "7.17.0", - "@wordpress/shortcode": "^4.17.0", - "@wordpress/warning": "^3.17.0", + "@wordpress/autop": "^4.18.0", + "@wordpress/blob": "^4.18.0", + "@wordpress/block-serialization-default-parser": "^5.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/dom": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/rich-text": "7.18.0", + "@wordpress/shortcode": "^4.18.0", + "@wordpress/warning": "^3.18.0", "change-case": "^4.1.2", "colord": "^2.7.0", "fast-deep-equal": "^3.1.3", @@ -7743,20 +7738,20 @@ } }, "node_modules/@wordpress/commands": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-1.17.0.tgz", - "integrity": "sha512-oZLv9pi0iiIO7DXRijK9gze5+iktoUyfDVipAmbmxAVEqptfWuPP3BRSkZxf+ccoIWpz0EhNKShsbQM86FwVbg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-1.18.0.tgz", + "integrity": "sha512-6MI5ff0SPn2NdO+qycNA09aTiMJ8BvwVmnC9c9jkOXyYEvYNLEPR6ez8wndMLXrJElGIFZRS5QjQDWQt8OmpgQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/components": "29.3.0", - "@wordpress/data": "10.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/keyboard-shortcuts": "^5.17.0", - "@wordpress/private-apis": "^1.17.0", + "@wordpress/components": "29.4.0", + "@wordpress/data": "10.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/keyboard-shortcuts": "^5.18.0", + "@wordpress/private-apis": "^1.18.0", "clsx": "^2.1.1", "cmdk": "^1.0.0" }, @@ -7770,9 +7765,9 @@ } }, "node_modules/@wordpress/components": { - "version": "29.3.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-29.3.0.tgz", - "integrity": "sha512-9lQIXsbgFeGY1QXEhNHQ6mq+6sS1TGGdZdaGSoQoP682WWgdjshnyq/0yhGULY9ReDKnZF2mHJ/J3FvleyYMcg==", + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-29.4.0.tgz", + "integrity": "sha512-jS0PnB/YpJuhFTGsFbRQYGD6w3zqqAkW+P1zv19crHNufrvNCv32jqNYv/2VIpEIZXKjo5Tkx0WvK8e1fm/7QA==", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -7787,23 +7782,23 @@ "@types/gradient-parser": "0.1.3", "@types/highlight-words-core": "1.2.1", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.17.0", - "@wordpress/compose": "7.17.0", - "@wordpress/date": "^5.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/dom": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/escape-html": "^3.17.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", - "@wordpress/keycodes": "^4.17.0", - "@wordpress/primitives": "4.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/rich-text": "7.17.0", - "@wordpress/warning": "^3.17.0", + "@wordpress/a11y": "^4.18.0", + "@wordpress/compose": "7.18.0", + "@wordpress/date": "^5.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/dom": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/escape-html": "^3.18.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", + "@wordpress/keycodes": "^4.18.0", + "@wordpress/primitives": "4.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/rich-text": "7.18.0", + "@wordpress/warning": "^3.18.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -7831,13 +7826,13 @@ } }, "node_modules/@wordpress/components/node_modules/@wordpress/keycodes": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.17.0.tgz", - "integrity": "sha512-6aZ28uoCmzjXONpRVtDPjevkw834fhIRBnn2KQdzENMnPiQCNbiG71mPNxkTw1yRHRRT5ptHvOe49ztWm9KMcA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.18.0.tgz", + "integrity": "sha512-96dkj/z0eDsA9kfM+vMsaY9MLUZGO9zJMlklkyaGltsVqrO/WhlNPEui7/JerJ+YUL0oncdUaGiwcj5pKs09cA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "5.17.0" + "@wordpress/i18n": "5.18.0" }, "engines": { "node": ">=18.12.0", @@ -7845,20 +7840,20 @@ } }, "node_modules/@wordpress/compose": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.17.0.tgz", - "integrity": "sha512-jn5uCw08HHLfOpIDp0pKBDZh1oZiMwjiK3c3IZdZo6eoWZjpOr3ecsMa4RBl/4HbqnUoeFDD6Lj83IEKPuzHQg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.18.0.tgz", + "integrity": "sha512-RtuSZyQu4dvbS6Ke7bDDN8O8qYwhJAvVn1hmreunQNQGSCH7vFYkzH4S5yphWOuPpUYNpMSbJnHJUPXuoojKkA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/dom": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", - "@wordpress/keycodes": "^4.17.0", - "@wordpress/priority-queue": "^3.17.0", - "@wordpress/undo-manager": "^1.17.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/dom": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", + "@wordpress/keycodes": "^4.18.0", + "@wordpress/priority-queue": "^3.18.0", + "@wordpress/undo-manager": "^1.18.0", "change-case": "^4.1.2", "clipboard": "^2.0.11", "mousetrap": "^1.6.5", @@ -7873,13 +7868,13 @@ } }, "node_modules/@wordpress/compose/node_modules/@wordpress/keycodes": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.17.0.tgz", - "integrity": "sha512-6aZ28uoCmzjXONpRVtDPjevkw834fhIRBnn2KQdzENMnPiQCNbiG71mPNxkTw1yRHRRT5ptHvOe49ztWm9KMcA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.18.0.tgz", + "integrity": "sha512-96dkj/z0eDsA9kfM+vMsaY9MLUZGO9zJMlklkyaGltsVqrO/WhlNPEui7/JerJ+YUL0oncdUaGiwcj5pKs09cA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "5.17.0" + "@wordpress/i18n": "5.18.0" }, "engines": { "node": ">=18.12.0", @@ -7887,29 +7882,29 @@ } }, "node_modules/@wordpress/core-data": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/core-data/-/core-data-7.17.0.tgz", - "integrity": "sha512-khNm8SDsIwXr1297e3j3Y/KHZmtRmouRgn+AWzlmlgdArsk8IlIwe9W+KE1tg+VoZJ5f3p0B7rqBUQfD7qbXQg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/core-data/-/core-data-7.18.0.tgz", + "integrity": "sha512-czih27KNEnJQDpY7m0VLwlpAEZZHa6IO7rXIwmCF9JnjYERM6gYPdBTApQUhuyc11QN9E6jzSgfE/5tzRQhd+g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/block-editor": "^14.12.0", - "@wordpress/blocks": "^14.6.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/rich-text": "7.17.0", - "@wordpress/sync": "^1.17.0", - "@wordpress/undo-manager": "^1.17.0", - "@wordpress/url": "4.17.0", - "@wordpress/warning": "^3.17.0", + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/block-editor": "^14.13.0", + "@wordpress/blocks": "^14.7.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/rich-text": "7.18.0", + "@wordpress/sync": "^1.18.0", + "@wordpress/undo-manager": "^1.18.0", + "@wordpress/url": "4.18.0", + "@wordpress/warning": "^3.18.0", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", "fast-deep-equal": "^3.1.3", @@ -7926,19 +7921,19 @@ } }, "node_modules/@wordpress/data": { - "version": "10.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.17.0.tgz", - "integrity": "sha512-NezfpsRH3BIV2i10wFohsGfOQ+pp9TvSHFuVK/AlQmnAogoMpFOxAumXCI7rvDoH1X4rEPiX2ggRnxP2+Z6jwQ==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.18.0.tgz", + "integrity": "sha512-GKuwP+0gJ5vUrgZ8xPIGUHTQcwzr2GzUuqbOX50exrd/V1veUa0wu8tILrflB6RYECdMdtoLxSfX+WVgtpEzlg==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/compose": "^7.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", - "@wordpress/priority-queue": "^3.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/redux-routine": "^5.17.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", + "@wordpress/priority-queue": "^3.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/redux-routine": "^5.18.0", "deepmerge": "^4.3.0", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.0.0", @@ -7961,22 +7956,22 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "node_modules/@wordpress/dataviews": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-4.13.0.tgz", - "integrity": "sha512-fJyHzNBvI/mivZh5z5+XC3tOSHojNOYVbSA9ifPB6hNcZjFJ+fsNt/I8tmOQdmOOb4dUESkOOKmk6RlPKCjErg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-4.14.0.tgz", + "integrity": "sha512-0PYrsnirj4FZxcrzaUlTxLSRFO9JVyCNfzhRYQGUrIPuAF2hBtYNUBNYZLcsCgcLG1W9NNcHJKX/SaBV47dF1g==", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", "@babel/runtime": "7.25.7", - "@wordpress/components": "29.3.0", - "@wordpress/compose": "7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/primitives": "4.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/warning": "^3.17.0", + "@wordpress/components": "29.4.0", + "@wordpress/compose": "7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/primitives": "4.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/warning": "^3.18.0", "clsx": "^2.1.1", "remove-accents": "^0.5.0" }, @@ -7989,13 +7984,13 @@ } }, "node_modules/@wordpress/date": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.17.0.tgz", - "integrity": "sha512-vFi+h+YpiicfDHtp1SKkFmgQR0PI9I76Dqoi7lBP95BPTGC/adQ3u2ee5wGd5uVUlR+ca+TfR6siC4Igau73oA==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.18.0.tgz", + "integrity": "sha512-x5k5pobYHq83BydLnHBOaMwuhqRafRYAgAs0aq5F08C5s33qg7JEKCJbiYZbAH/d9MeDrfAQwlFE9oj5Fx8yew==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/deprecated": "^4.17.0", + "@wordpress/deprecated": "^4.18.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40" }, @@ -8005,9 +8000,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.17.0.tgz", - "integrity": "sha512-aRiYH1lcgxnvo0dvhEd5dxjBiWQokRdzSHFSF5flZ4vmHVvDRSgj5V0CQTuCG4fr77PwEJNjPHOm+s1JbmmQJw==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.18.0.tgz", + "integrity": "sha512-OKW8CwbZEU5AoTx+hW00iaWBUywa2xuX5nrJuqYOAMi4OVgjiNqcjUmamAHNPWtw4jvxBbodd78B18BHRtEonQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8022,13 +8017,13 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.17.0.tgz", - "integrity": "sha512-7IlFpQ6tNkUbOuuxm6kBCR2R6C9Etlzojgh0ykJ/OmwgRMrosH/m6/zAmaA15oRYpd6dvO7ozJN+ArPz7LSOiQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.18.0.tgz", + "integrity": "sha512-GtTmirQsz4NmSLOyLGL6MmWBM41M0lAet2nWhkf8gmyoO2n7bc7UpyqxiyijW7R6N2imvvJyD5ZMqLRBBVk/IQ==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/hooks": "4.17.0" + "@wordpress/hooks": "4.18.0" }, "engines": { "node": ">=18.12.0", @@ -8036,13 +8031,13 @@ } }, "node_modules/@wordpress/dom": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.17.0.tgz", - "integrity": "sha512-raAeub1L/a2yHd9rwCGs67yDSUsafcpERi9rJCeHiaBE/+h7gZn7Li+Pya+DMk7tGxoIHNpPuGVTAyVhQbjWdQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.18.0.tgz", + "integrity": "sha512-EHGyFnr/XciaeivOkGlny+LZMce3keVDDV3ioanmZ8ei6XBvMhu53ckBRjGstMVC7WtEpcCp65pjIvdCCiFDtQ==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/deprecated": "^4.17.0" + "@wordpress/deprecated": "^4.18.0" }, "engines": { "node": ">=18.12.0", @@ -8050,9 +8045,9 @@ } }, "node_modules/@wordpress/dom-ready": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.17.0.tgz", - "integrity": "sha512-u/ocyrPV4MJIKxM1OJg+Q6yOBD0pIYi1jcXE1HVYnc/9Mte0IFlfovYRJj6oGUc7u4dM6AVE2BUCQMJgmG406Q==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.18.0.tgz", + "integrity": "sha512-LRzvHy1OTiqm7Uww70ByjqLzovoXRy6PPKAlHUrFxuK/0v9QNJ5ha0Q57PYwrZZVm2Hjkt2SQ8OdIZESaoI2/g==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -8063,9 +8058,9 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.17.0.tgz", - "integrity": "sha512-KhS+HyduYVHWbB/uHxQUC1wHMACx2BpP+4euMN8Kimy/rIsyOFrav9ueVGn7fHu9wu++swk8nUWFBip3GdsliA==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.18.0.tgz", + "integrity": "sha512-SGYU724cFM/EmDoTu9pVI+H2PLln9/NIKaxOLLhMhCBqb15nf6VzBU1ux1qHtG6YDA62S+kxO3gyI40amvX6Sg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8085,48 +8080,48 @@ } }, "node_modules/@wordpress/editor": { - "version": "14.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/editor/-/editor-14.17.0.tgz", - "integrity": "sha512-sAeXBvg22o74v7acuYOyHcoALwOs/yzLdXZ8mqT/oi9kBiXSW7kWaPg/q10Mqst0Y6F+prVjNVxcUqHx+tcT5g==", + "version": "14.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/editor/-/editor-14.18.0.tgz", + "integrity": "sha512-SN/B8onomEBg4sHYihgSJesIZ3wi8QlecEDGe6NeAcf/yugDkUW2k/lTLhBLGgA7tEfLEi5vsKDhlVjpHjb4zA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.17.0", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/blob": "^4.17.0", - "@wordpress/block-editor": "^14.12.0", - "@wordpress/blocks": "^14.6.0", - "@wordpress/commands": "^1.17.0", - "@wordpress/components": "^29.3.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/core-data": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/dataviews": "^4.13.0", - "@wordpress/date": "^5.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/dom": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/fields": "^0.9.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/interface": "^9.2.0", - "@wordpress/keyboard-shortcuts": "^5.17.0", - "@wordpress/keycodes": "^4.17.0", - "@wordpress/media-utils": "^5.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/patterns": "^2.17.0", - "@wordpress/plugins": "7.17.0", - "@wordpress/preferences": "^4.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/reusable-blocks": "^5.17.0", - "@wordpress/rich-text": "7.17.0", - "@wordpress/server-side-render": "5.17.0", - "@wordpress/url": "4.17.0", - "@wordpress/warning": "^3.17.0", - "@wordpress/wordcount": "^4.17.0", + "@wordpress/a11y": "^4.18.0", + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/blob": "^4.18.0", + "@wordpress/block-editor": "^14.13.0", + "@wordpress/blocks": "^14.7.0", + "@wordpress/commands": "^1.18.0", + "@wordpress/components": "^29.4.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/core-data": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/dataviews": "^4.14.0", + "@wordpress/date": "^5.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/dom": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/fields": "^0.10.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/interface": "^9.3.0", + "@wordpress/keyboard-shortcuts": "^5.18.0", + "@wordpress/keycodes": "^4.18.0", + "@wordpress/media-utils": "^5.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/patterns": "^2.18.0", + "@wordpress/plugins": "7.18.0", + "@wordpress/preferences": "^4.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/reusable-blocks": "^5.18.0", + "@wordpress/rich-text": "7.18.0", + "@wordpress/server-side-render": "5.18.0", + "@wordpress/url": "4.18.0", + "@wordpress/warning": "^3.18.0", + "@wordpress/wordcount": "^4.18.0", "change-case": "^4.1.2", "client-zip": "^2.4.5", "clsx": "^2.1.1", @@ -8149,14 +8144,14 @@ } }, "node_modules/@wordpress/editor/node_modules/@wordpress/keycodes": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.17.0.tgz", - "integrity": "sha512-6aZ28uoCmzjXONpRVtDPjevkw834fhIRBnn2KQdzENMnPiQCNbiG71mPNxkTw1yRHRRT5ptHvOe49ztWm9KMcA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.18.0.tgz", + "integrity": "sha512-96dkj/z0eDsA9kfM+vMsaY9MLUZGO9zJMlklkyaGltsVqrO/WhlNPEui7/JerJ+YUL0oncdUaGiwcj5pKs09cA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "5.17.0" + "@wordpress/i18n": "5.18.0" }, "engines": { "node": ">=18.12.0", @@ -8164,15 +8159,15 @@ } }, "node_modules/@wordpress/element": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.17.0.tgz", - "integrity": "sha512-mRLFDPmZiI3+POi/iUGoof/9fQi4YTJ/RAuIUipr7yG7l4SwOoQy4eSJy6QTyqtJxZ+/7qA+b/+Ek15UzFst5Q==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.18.0.tgz", + "integrity": "sha512-38wA2ta4GGOxuKofmyNP+EWA03bqBwUufQNQNd8GgcaKo4N/j+0wQMouLC/2g6n9MU5B0DH4tIv9uP9qR1miYg==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", - "@wordpress/escape-html": "^3.17.0", + "@wordpress/escape-html": "^3.18.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", @@ -8184,9 +8179,9 @@ } }, "node_modules/@wordpress/env": { - "version": "10.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.17.0.tgz", - "integrity": "sha512-Wy0DpOwOy2gtmtad9inkEydAfBm5+TMulCfh22oi5hFVy6BaPpS+Dm9yjgzd9YtO13CR1Y/WjcqYJxEMOFVEJA==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.18.0.tgz", + "integrity": "sha512-u724CaGEbMaY1K43UF76LiPvG/hm8lKoPWO4xq04tN+KkUMR3LgrULEuuPOU0QJQNEJVAdrZpsIZ/DF9owiwfg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8247,9 +8242,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.17.0.tgz", - "integrity": "sha512-yOfJwgmrtIXQDwX6zTC0L7ymYBXz3K3hlW0nDdtYy+bCw5z0gbrEOnBotOD6YdXlejAgnaAH+K1VSf0xxG5uGA==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.18.0.tgz", + "integrity": "sha512-5hcsoIin5IYAEejuRJqnFQB5q5C0q8VII/H7wWOWoe8IXiRQCiju/DWRC25AL2xWdUdaqiG84JuiPq+7Npb8gw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -8260,36 +8255,36 @@ } }, "node_modules/@wordpress/fields": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/fields/-/fields-0.9.0.tgz", - "integrity": "sha512-PgfXdLu22ZKSz4Ro9sDrKjINS0nCLb4EOLGhyN7RxuXXVW9v+UAhnIX/WCpzoixRX5s7uycDbntt5fklfCTiVg==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/fields/-/fields-0.10.0.tgz", + "integrity": "sha512-1JcX5EdYiw43Mx3qiTTPE295FsqHJ1d8hnaMFQhdLuVhMRlz1hOSXTn6wL6BwcCsc1/KOBLX7OAFbNohwGGbqg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/blob": "^4.17.0", - "@wordpress/block-editor": "^14.12.0", - "@wordpress/blocks": "^14.6.0", - "@wordpress/components": "^29.3.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/core-data": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/dataviews": "^4.13.0", - "@wordpress/date": "^5.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/media-utils": "^5.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/patterns": "^2.17.0", - "@wordpress/primitives": "4.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/router": "^1.17.0", - "@wordpress/url": "4.17.0", - "@wordpress/warning": "^3.17.0", + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/blob": "^4.18.0", + "@wordpress/block-editor": "^14.13.0", + "@wordpress/blocks": "^14.7.0", + "@wordpress/components": "^29.4.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/core-data": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/dataviews": "^4.14.0", + "@wordpress/date": "^5.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/media-utils": "^5.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/patterns": "^2.18.0", + "@wordpress/primitives": "4.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/router": "^1.18.0", + "@wordpress/url": "4.18.0", + "@wordpress/warning": "^3.18.0", "change-case": "4.1.2", "client-zip": "^2.4.5", "clsx": "2.1.1", @@ -8304,9 +8299,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.17.0.tgz", - "integrity": "sha512-LGOHGuwCXCevuzaFpM2sgyPZxf3H7tWaSKzlvDzx2kmwiWIrFug/yebywv4Cxsl82I5DfZkDpxXRpqTxXrC0Nw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.18.0.tgz", + "integrity": "sha512-hJulGAT2ELS+UBCTPnTe2A2ep+sOF4PO/x41UmeSgiaIJV1G4xNCJnlcyuCsV3xI2CTnf+YqjyS3qbqhLq3YOA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -8317,9 +8312,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.17.0.tgz", - "integrity": "sha512-8cVD8KTxsKLHA9r6Lt3fkQoNBUQ6zMWdgaK1VNRYRJgTfx8C6FlNBjvHrIIgS0nJ43k9iAmAObGQiL3GkGVI1g==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.18.0.tgz", + "integrity": "sha512-g0XTnspjAQNKpj3dVcFxYZaDcxbEfAx9rsosYksgnvaVoMBvCmzlaNIa6QAjmsjwryoNi8ugx2CkCxde1lVp2Q==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -8330,13 +8325,13 @@ } }, "node_modules/@wordpress/i18n": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.17.0.tgz", - "integrity": "sha512-aAsYls8sTTSEimsvjxBl9mCYbZYD3BddHVpuHgbBxzC+2SZE+JYJ+IpcwEghC712qo0jEkG8Vdzhqae1PL6vCQ==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.18.0.tgz", + "integrity": "sha512-XaA3qSyOnmx7FEQFwOXxqWLVjKw0TWR/S/sEmp6Rs88ttUDS0Y6z6xGwCwEK8acmUUWccaxk6aPirX8gV4Bdrw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/hooks": "^4.17.0", + "@wordpress/hooks": "^4.18.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "sprintf-js": "^1.1.1", @@ -8351,14 +8346,14 @@ } }, "node_modules/@wordpress/icons": { - "version": "10.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-10.17.0.tgz", - "integrity": "sha512-qzWFrMfa5HZdGxGq7I+s9bmUJqZrFfx6ow/slY1USKJqp1uRHRekAbq6UrOrJscs8rSUQiV/aNNPDgSfqBEM6A==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-10.18.0.tgz", + "integrity": "sha512-stWW0msTspv6wCB3Pcu7PtVoFQBRr4R+oOvXlxVnpsUKuUz+mIAS08Sme9bTkHiVtHBZRK6YGpt4wYK6W0FXcw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/element": "^6.17.0", - "@wordpress/primitives": "4.17.0" + "@wordpress/element": "^6.18.0", + "@wordpress/primitives": "4.18.0" }, "engines": { "node": ">=18.12.0", @@ -8366,9 +8361,9 @@ } }, "node_modules/@wordpress/interactivity": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-6.17.0.tgz", - "integrity": "sha512-lhDqh0iyfG6DXwYXfg4u0EP9EofRBiVt7Lszn1LIgFFuThHBDyNgePKW6WxZhW9Nrwq9pan7gvCWIx6IKZkg8Q==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-6.18.0.tgz", + "integrity": "sha512-FW2lvpSALQV21sIOXEcg2OgcXb7stHLnVdiEX/ceLZO2UElOQGOT0uvVSeIEqsL/lUXlyIGDku7DZ9fjq0s2Ng==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8381,24 +8376,24 @@ } }, "node_modules/@wordpress/interface": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@wordpress/interface/-/interface-9.2.0.tgz", - "integrity": "sha512-WO4aWZYFlrqchKpgWttK9PB4xIicdatp4cUX7Diw3b/Zltq4+aE+DddTDeRvqLoi+NdgPlJK/tNxBaU4UoiBlQ==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/interface/-/interface-9.3.0.tgz", + "integrity": "sha512-mumbsuAzLyUvDnzMUJCYtO26C74yPtMQ1Se8MoPyQq5e+1bPjd/MtjBWXYqY+aNS31yme/n7N0X77ea7w6t7YQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.17.0", - "@wordpress/components": "^29.3.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/plugins": "7.17.0", - "@wordpress/preferences": "^4.17.0", - "@wordpress/viewport": "^6.17.0", + "@wordpress/a11y": "^4.18.0", + "@wordpress/components": "^29.4.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/plugins": "7.18.0", + "@wordpress/preferences": "^4.18.0", + "@wordpress/viewport": "^6.18.0", "clsx": "^2.1.1" }, "engines": { @@ -8411,9 +8406,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.17.0.tgz", - "integrity": "sha512-PRykD6MgDkptKsKwETjNHiQUVtaegXkREX6UetN1iL6u+2la4XC/naDHByq7TL+Cg4snyR+PlNdw45Y4dgMf5w==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.18.0.tgz", + "integrity": "sha512-g8MGRyDTwpJXK/Pxp1aU/uprBV1pqYcB4sBlt5E5rck/8OlAwYzePgR7TShBT6kLs9UGi9a3k8StPilnX54GMQ==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -8459,16 +8454,16 @@ } }, "node_modules/@wordpress/keyboard-shortcuts": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keyboard-shortcuts/-/keyboard-shortcuts-5.17.0.tgz", - "integrity": "sha512-XQbtiTSq6rsP/5KYMMDCmZegABlqcq7IpLtymrbeQNSPjyAP4aflU0rCcNWaXhBbdWWDRmaU9u/X1/fI5wGxUQ==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keyboard-shortcuts/-/keyboard-shortcuts-5.18.0.tgz", + "integrity": "sha512-FY+j85svtDR3fsfLbzKUClxTYaBVgCWLwYxlyNVF07uVZRSn6Y3josXrfhn1YzBH9lvzyxIb7pZprFVRFUwtKw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/data": "10.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/keycodes": "^4.17.0" + "@wordpress/data": "10.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/keycodes": "^4.18.0" }, "engines": { "node": ">=18.12.0", @@ -8479,14 +8474,14 @@ } }, "node_modules/@wordpress/keyboard-shortcuts/node_modules/@wordpress/keycodes": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.17.0.tgz", - "integrity": "sha512-6aZ28uoCmzjXONpRVtDPjevkw834fhIRBnn2KQdzENMnPiQCNbiG71mPNxkTw1yRHRRT5ptHvOe49ztWm9KMcA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.18.0.tgz", + "integrity": "sha512-96dkj/z0eDsA9kfM+vMsaY9MLUZGO9zJMlklkyaGltsVqrO/WhlNPEui7/JerJ+YUL0oncdUaGiwcj5pKs09cA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "5.17.0" + "@wordpress/i18n": "5.18.0" }, "engines": { "node": ">=18.12.0", @@ -8539,18 +8534,18 @@ } }, "node_modules/@wordpress/media-utils": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/media-utils/-/media-utils-5.17.0.tgz", - "integrity": "sha512-AyTz5C0NxZ69v+rQ3I/g7cPBa9DL8+pBufHZ5Ewz47q6hwSSb3j8+xTgfl/ndKCc/Taqvr4Sgd4QijOUR+iQ3A==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/media-utils/-/media-utils-5.18.0.tgz", + "integrity": "sha512-qjsM/ujuqVOkknLSt67dX1w47WT+5Vfmz2urp7c/lidUKzBpRYcJhDpm1iQCuHJ8TCL6tVz0O29oNGFyItYBIg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/blob": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/private-apis": "^1.17.0" + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/blob": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/private-apis": "^1.18.0" }, "engines": { "node": ">=18.12.0", @@ -8558,15 +8553,15 @@ } }, "node_modules/@wordpress/notices": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-5.17.0.tgz", - "integrity": "sha512-1qsRcxE2dnvIJO9IQHnK9D/U/RgRmccDhbNrBxcgOqEVHTFwDambuxte4JXOmJZVr+uqh8Z3ggr+4H6zCjs/9Q==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-5.18.0.tgz", + "integrity": "sha512-JBEbMtcdKn1hTzSLi6TPgO+09VHyOdlhyuVVV56rxg1S6bmq2DKvLuMYFiF4KpBaDHPPqQho9adHpozx1TAYkg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.17.0", - "@wordpress/data": "^10.17.0" + "@wordpress/a11y": "^4.18.0", + "@wordpress/data": "^10.18.0" }, "engines": { "node": ">=18.12.0", @@ -8590,27 +8585,27 @@ } }, "node_modules/@wordpress/patterns": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/patterns/-/patterns-2.17.0.tgz", - "integrity": "sha512-NPTYVeBVl7+wcXDP1YJbubVYo3xroExrgbWsH6kpl4sK6f7ZvCa7Ka/Na8WL0MXJbhhpw3S+zeUL8QOxKKeWGg==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/patterns/-/patterns-2.18.0.tgz", + "integrity": "sha512-84+yM89KbKjFklqT5nJ6FNnXjadN+Ypn21SARbe+F9D5Pfd+JsxTfMVz6PjxzIrDr9O0594VSbxu9ISSlDy3jA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.17.0", - "@wordpress/block-editor": "^14.12.0", - "@wordpress/blocks": "^14.6.0", - "@wordpress/components": "^29.3.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/core-data": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/html-entities": "^4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/url": "4.17.0" + "@wordpress/a11y": "^4.18.0", + "@wordpress/block-editor": "^14.13.0", + "@wordpress/blocks": "^14.7.0", + "@wordpress/components": "^29.4.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/core-data": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/html-entities": "^4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/url": "4.18.0" }, "engines": { "node": ">=18.12.0", @@ -8622,20 +8617,20 @@ } }, "node_modules/@wordpress/plugins": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/plugins/-/plugins-7.17.0.tgz", - "integrity": "sha512-CoVDWqUq3gXiv8TFJz+vFvTuAvbq2h0Ct8ciH+tGi7SykhA35GqnCcfR/aKDOlAXHGpD0vwxV0iv08kmhIVQ/A==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/plugins/-/plugins-7.18.0.tgz", + "integrity": "sha512-WcgesvabfDD2vaNGpPRRRCN1coUgnpX+LVWMl+x5xISRxnTeeTbAPzl125B7xhv+N98JcQCZOen0+xO3/4yIRA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/components": "^29.3.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "^6.17.0", - "@wordpress/hooks": "^4.17.0", - "@wordpress/icons": "^10.17.0", - "@wordpress/is-shallow-equal": "^5.17.0", + "@wordpress/components": "^29.4.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "^6.18.0", + "@wordpress/hooks": "^4.18.0", + "@wordpress/icons": "^10.18.0", + "@wordpress/is-shallow-equal": "^5.18.0", "memize": "^2.0.1" }, "engines": { @@ -8665,22 +8660,22 @@ } }, "node_modules/@wordpress/preferences": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.17.0.tgz", - "integrity": "sha512-jNyHhuar2RflBJ9JqGs0ZQXnU86URCQXlR4syXzZdVU75Sm1fPByqKDtR9/F/bWnPxLlU1uP89SKv54kGpSM4Q==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.18.0.tgz", + "integrity": "sha512-hFpzDgZim4dH07IVtxAgGjN3mYDHWWPHrA6nqA0RxhU4MalRB6jvL+AWFKJtWk3P4bv14O854s7zJ+x5Dj6Ceg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.17.0", - "@wordpress/components": "29.3.0", - "@wordpress/compose": "7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/private-apis": "^1.17.0", + "@wordpress/a11y": "^4.18.0", + "@wordpress/components": "29.4.0", + "@wordpress/compose": "7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/private-apis": "^1.18.0", "clsx": "^2.1.1" }, "engines": { @@ -8693,13 +8688,13 @@ } }, "node_modules/@wordpress/primitives": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.17.0.tgz", - "integrity": "sha512-O1dysI/Y9xv5uUMllH2VIxuBDCOVUX8WmouE9KKr11Yv4gkHzxzaU2M5rFtu7RbUCv6jtkvjidy2cuZuNpEIHQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.18.0.tgz", + "integrity": "sha512-c/8wC1MgbiQc/8kQlxuNuzeDeEW65jHSK0/qZ/afvHL1T4AmYmyC791nabpyGM+Le1kwe1LCDbPo/x2Oig0nbA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/element": "^6.17.0", + "@wordpress/element": "^6.18.0", "clsx": "^2.1.1" }, "engines": { @@ -8711,9 +8706,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.17.0.tgz", - "integrity": "sha512-WzQHNx6wjgbxhuaKErjIRLSL9E9La8slsAXRTQPmkgvKqa11Rh4RYl2FLUh8tABK3xo5HzaHCplkZSm2q5wlbg==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.18.0.tgz", + "integrity": "sha512-B8ZRIu91hnfs/O3P6sGW+sQThrMgF9qGzjIfqxPmKE8npn9wjAERxyrnyBy/pEdn1mI1gRHtaKqtuirPjBYmfw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -8725,9 +8720,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.17.0.tgz", - "integrity": "sha512-9NGPyuUvtJD0OjWJ/Cn+6Qhjb8hXhiJH4i80W7MFVHRgUZLc/Tu5BOg2+OnXMRSePbgYivo1NLEukqdXqse5IA==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.18.0.tgz", + "integrity": "sha512-yNt9cxdvIS60iWz/yy0qtsrZAYO8imkWA+xOYO7n5/N/kXchtCx4c6+XNMdX7ftyBrygOXJtsqkVoLUORPeKxw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -8738,9 +8733,9 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.17.0.tgz", - "integrity": "sha512-RBUNOp+wSweymRB0+fThv1HKUf1c8GVMUT/Xv0kqtrRsGFD70ciwnnfVXnPY0V6po9Uzj5Bb4+2qO/l/e2IwXw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.18.0.tgz", + "integrity": "sha512-QlG5kKuUITzpVH3vrTuLiRgm9Zxe/QlG9xS/BYTiMorM9tPvwFbko244aDZbzghl4IkZub2ImucfzmxzoH7Ltg==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -8757,24 +8752,24 @@ } }, "node_modules/@wordpress/reusable-blocks": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/reusable-blocks/-/reusable-blocks-5.17.0.tgz", - "integrity": "sha512-VxKBz1KZCTSnhdiaoNbcQrFW9dqRNEkGP60guWqqFlSYl5SpPqulwhtNCpfIw2Z9z8oYMGa7/2JO64WiVeYwGA==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/reusable-blocks/-/reusable-blocks-5.18.0.tgz", + "integrity": "sha512-Uz03XQJOhN4zR3uUi96HXebNDFfveO8AKLkHUUrkgpWJqe9mhOCweJle1303ZraY+H5vrPDjY642Z2Vykaw35w==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/block-editor": "^14.12.0", - "@wordpress/blocks": "^14.6.0", - "@wordpress/components": "^29.3.0", - "@wordpress/core-data": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/url": "4.17.0" + "@wordpress/block-editor": "^14.13.0", + "@wordpress/blocks": "^14.7.0", + "@wordpress/components": "^29.4.0", + "@wordpress/core-data": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/url": "4.18.0" }, "engines": { "node": ">=18.12.0", @@ -8786,20 +8781,20 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.17.0.tgz", - "integrity": "sha512-HEmApVDjConxYe3cP8P+Zs0xLJZPMhfWal38MQmFelQtCNk+kT0IBg5SkFAcWYY+c4gzhK+dMKawc72uWDfm8w==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.18.0.tgz", + "integrity": "sha512-pFFyjgbE2nQVYclKdttrBgWw5FUDubQdU8tQePjTSdMzLi6Lbod+A8FaK7wSsmfrzA3CFnMkLIhix8PHYyJ1sQ==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.17.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "^6.17.0", - "@wordpress/escape-html": "^3.17.0", - "@wordpress/i18n": "^5.17.0", - "@wordpress/keycodes": "^4.17.0", + "@wordpress/a11y": "^4.18.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "^6.18.0", + "@wordpress/escape-html": "^3.18.0", + "@wordpress/i18n": "^5.18.0", + "@wordpress/keycodes": "^4.18.0", "memize": "^2.1.0" }, "engines": { @@ -8811,13 +8806,13 @@ } }, "node_modules/@wordpress/rich-text/node_modules/@wordpress/keycodes": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.17.0.tgz", - "integrity": "sha512-6aZ28uoCmzjXONpRVtDPjevkw834fhIRBnn2KQdzENMnPiQCNbiG71mPNxkTw1yRHRRT5ptHvOe49ztWm9KMcA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.18.0.tgz", + "integrity": "sha512-96dkj/z0eDsA9kfM+vMsaY9MLUZGO9zJMlklkyaGltsVqrO/WhlNPEui7/JerJ+YUL0oncdUaGiwcj5pKs09cA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/i18n": "^5.17.0" + "@wordpress/i18n": "^5.18.0" }, "engines": { "node": ">=18.12.0", @@ -8825,17 +8820,17 @@ } }, "node_modules/@wordpress/router": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/router/-/router-1.17.0.tgz", - "integrity": "sha512-hzc3Hdbnje7Bl/MHCfDnTbjVwyoVR6Cp05H1N1f6pAbqSTgHTyefMkkK4CPtwplpYcsY+yvEfPij1GejcFaAgg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/router/-/router-1.18.0.tgz", + "integrity": "sha512-waO2Uqps7n71S1UhUPjWwZObYBIrb6F9heTmwbAK+O0xUIgTkDcYO654CBAYbbv3RbAOZEZNdRHzfjW4i6yiPw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/compose": "^7.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/url": "4.17.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/url": "4.18.0", "history": "^5.3.0", "route-recognizer": "^0.3.4" }, @@ -9493,22 +9488,22 @@ } }, "node_modules/@wordpress/server-side-render": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/server-side-render/-/server-side-render-5.17.0.tgz", - "integrity": "sha512-xJWABbtCZmkO6+Xa1DS3Mq+f2ZKH540aj5xeN7M1W1meAFdcZlEAbQI+Kn1PuXI9VpHIh5K+JOybHD06TI4hZQ==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/server-side-render/-/server-side-render-5.18.0.tgz", + "integrity": "sha512-pFND5MDe0yJapXYAdd5DwY8QScCKCTTjk0vRS6MA7laGPOjH9nXUJ0Er+W2mh7EamPbDljhdXv8/l8iu9ZR7dg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/blocks": "^14.6.0", - "@wordpress/components": "^29.3.0", - "@wordpress/compose": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/deprecated": "^4.17.0", - "@wordpress/element": "^6.17.0", - "@wordpress/i18n": "^5.17.0", - "@wordpress/url": "4.17.0", + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/blocks": "^14.7.0", + "@wordpress/components": "^29.4.0", + "@wordpress/compose": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/deprecated": "^4.18.0", + "@wordpress/element": "^6.18.0", + "@wordpress/i18n": "^5.18.0", + "@wordpress/url": "4.18.0", "fast-deep-equal": "^3.1.3" }, "engines": { @@ -9521,9 +9516,9 @@ } }, "node_modules/@wordpress/shortcode": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.17.0.tgz", - "integrity": "sha512-sNPUmeeK/dxK5z8BWSsk5OqRSf2UzfczpKu3upRn9eIdgG31SCXPgzvps73upIrxZNDCTQVVFhq47KADX8TiUA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.18.0.tgz", + "integrity": "sha512-XMneQtxOvGmeqhQIURwtNN4otT/TGRhh87vxZuQYbAHQ5d+JeeFbLgferViZLWSNOJ2jioWvJGJIbIYshDrmhQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9536,9 +9531,9 @@ } }, "node_modules/@wordpress/style-engine": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-2.17.0.tgz", - "integrity": "sha512-6eIdeQH0t7va1AjZIGo8sEW8NE+dcz//KXp+HsW/2XhATAIPjUjFJ2/SVRNCj3JHFKSjKpxnZi26xalfET0PqA==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-2.18.0.tgz", + "integrity": "sha512-JyPfIe+Kt/R1Q4snab453S/Xumo8RwaTQviU0dYPawKP2R3Gd9uHFr+tVDBgJxYIJbAFfOMJdEiLkkOktfEAPQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9551,15 +9546,15 @@ } }, "node_modules/@wordpress/sync": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/sync/-/sync-1.17.0.tgz", - "integrity": "sha512-otylLNYzW0Tu5NIgLwGwE2rvjikyB3KCFlpqIl4otR1XxqFM7obHG7VU+e0LKQdlg6NIdCZdWyv2nNGnz5cjFg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/sync/-/sync-1.18.0.tgz", + "integrity": "sha512-LZaUH2iXSiISt5EPctKLS7cWgqU1PTFoElPqGA1Xt0/vxQXjNKYArXuciiqSkGj0tGo5jno9RYexBGNLWhvbUw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", - "@wordpress/url": "4.17.0", + "@wordpress/url": "4.18.0", "import-locals": "^2.0.0", "lib0": "^0.2.42", "simple-peer": "^9.11.0", @@ -9574,9 +9569,9 @@ } }, "node_modules/@wordpress/token-list": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-3.17.0.tgz", - "integrity": "sha512-TO224Seolfy/eapbOg15poz1Ws44xW3KHrqeo7Jp+6hmqQh/5OJE5wDFTzgsbdnAXFzy3DAGJxxxrCv0qpf+YA==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-3.18.0.tgz", + "integrity": "sha512-90AxsuNkAV22O2EahlCS98teraMGdQGG5LZqIgpQMqNoBGTfs7/F6Jgq1xBypDz+Fh7FVNNkdF7ANGVYH+b26g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9588,13 +9583,13 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.17.0.tgz", - "integrity": "sha512-inSOCUneGMmFq3jRTB9uIws/+6VWpz0zvY2IPW/vjWbz7Gg1YbJ+lmbbgtJCoiJ7Ei00b4sagvzI00TNUXe9mg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.18.0.tgz", + "integrity": "sha512-OmOCihoOzWgyhs+6BFJJcGxgPby8sd1y0zlW+PuGOeNxtxLVa9Q4cGDcUE0++OcWk9b/0S0l4XrzwKTC4eyiQw==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/is-shallow-equal": "^5.17.0" + "@wordpress/is-shallow-equal": "^5.18.0" }, "engines": { "node": ">=18.12.0", @@ -9602,23 +9597,23 @@ } }, "node_modules/@wordpress/upload-media": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.2.0.tgz", - "integrity": "sha512-xPPru9rSDTKWpFMMM5dOaPQIkf38L3gNinjSHkU7arFyK14G60HklvZJ/MTk7RjjgQ7h1sYe8tvdiTvI8CQZyQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.3.0.tgz", + "integrity": "sha512-tX7R4UKigkfe9m9BlS/jVJ9uOIrHGPJf6fTScqhCgmRiXKMymwtPFS+3OP9yCQe4Tz1bNyhhzPBhaFWFI7WJ1w==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", "@shopify/web-worker": "^6.4.0", - "@wordpress/api-fetch": "^7.17.0", - "@wordpress/blob": "^4.17.0", - "@wordpress/compose": "7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/preferences": "^4.17.0", - "@wordpress/private-apis": "^1.17.0", - "@wordpress/url": "4.17.0", + "@wordpress/api-fetch": "^7.18.0", + "@wordpress/blob": "^4.18.0", + "@wordpress/compose": "7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/preferences": "^4.18.0", + "@wordpress/private-apis": "^1.18.0", + "@wordpress/url": "4.18.0", "uuid": "^9.0.1" }, "engines": { @@ -9631,9 +9626,9 @@ } }, "node_modules/@wordpress/url": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.17.0.tgz", - "integrity": "sha512-aFU1w2Wcz2/YdapPYozeXbb7C7LzfYZmAg4Bu28zTSxxrpKYocr/oYH7D8V13uHzfBoqTzL8XYM7wj17Dlcdag==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.18.0.tgz", + "integrity": "sha512-Jr1O9NjNKQuWRIBzc0G/aiHt54vXkCJ50JJiKAAFnz6yTE6XVoPvxDG3nidGAThNWQgZ1UykY6Zir+XrQPHBtw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9646,16 +9641,16 @@ } }, "node_modules/@wordpress/viewport": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/viewport/-/viewport-6.17.0.tgz", - "integrity": "sha512-xhTOdRjA2bjmuWOYoJtq9Tdnjle7u0bCkJyyuCVrMWxqAunxcI8QxSTXm9OqvuAVbvGfhH9i/BIeeTQjFYPxPA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/viewport/-/viewport-6.18.0.tgz", + "integrity": "sha512-MokrS9MiDh6g4Vzl0MSU1LKuTBLcydZjvryYjLnfd1BqE0DGNBOMtCe1L7RwXEkbxbhv4UCc7xZ5G0CUeOaGDw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/compose": "^7.17.0", - "@wordpress/data": "^10.17.0", - "@wordpress/element": "6.17.0" + "@wordpress/compose": "^7.18.0", + "@wordpress/data": "^10.18.0", + "@wordpress/element": "6.18.0" }, "engines": { "node": ">=18.12.0", @@ -9666,9 +9661,9 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.17.0.tgz", - "integrity": "sha512-dmEjDbYtfPD8rMRtSrLxoW3g8CLKl+vK5pdXvDvG0lBoRjqwtRPP4cgNBOC8cq8gXRCwh5NDDtM2C8MTjGjVsQ==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.18.0.tgz", + "integrity": "sha512-Y58A1D38PJbQzFZXokL+4rACzs5Vp5cZMZc/9J+QOYStJdy/xEDQttbhte2P7kq0yF3EUZDC0QnG8RsIV09WSQ==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -9676,9 +9671,9 @@ } }, "node_modules/@wordpress/wordcount": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-4.17.0.tgz", - "integrity": "sha512-lT4NmbK0fMX+mqm/1XSoTsW7VqmxApZcZFPtWvT5UH6js1XcDrQa9liIUv6RyMlrrLHTTDrq+e4mNVeND68o5A==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-4.18.0.tgz", + "integrity": "sha512-JNflIJoeuLDjzK2k9JdHcY+RhJ9cr0iHO8lgtVOTYSqF5tGpk1+QiA8Y/cyONJNzBbUO48C/G+gEsRk5fyXNhA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -15593,9 +15588,9 @@ "dev": true }, "node_modules/happy-dom": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.8.1.tgz", - "integrity": "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.1.0.tgz", + "integrity": "sha512-9tUhXyePCjzUMycaHS/IzrIpF69xiq/laAT7golk4MtZ6t8ft5+Rv7U3lfrs2b4NMH0JTL3EhZzjfahrPmOnaQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fa950dfe..f29467b1 100644 --- a/package.json +++ b/package.json @@ -63,35 +63,35 @@ "@testing-library/react": "16.2.0", "@types/react-syntax-highlighter": "^15.5.13", "@types/wordpress__block-editor": "11.5.16", - "@types/wordpress__blocks": "12.5.16", + "@types/wordpress__blocks": "12.5.17", "@vitest/coverage-v8": "3.0.4", - "@wordpress/api-fetch": "7.17.0", - "@wordpress/block-editor": "14.12.0", - "@wordpress/blocks": "14.6.0", - "@wordpress/components": "29.3.0", - "@wordpress/compose": "7.17.0", - "@wordpress/core-data": "7.17.0", - "@wordpress/data": "10.17.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.17.0", - "@wordpress/dom-ready": "4.17.0", - "@wordpress/e2e-test-utils-playwright": "^1.17.0", - "@wordpress/editor": "14.17.0", - "@wordpress/element": "6.17.0", - "@wordpress/env": "10.17.0", - "@wordpress/hooks": "4.17.0", - "@wordpress/i18n": "5.17.0", - "@wordpress/icons": "10.17.0", - "@wordpress/interactivity": "6.17.0", - "@wordpress/notices": "5.17.0", - "@wordpress/plugins": "7.17.0", - "@wordpress/primitives": "4.17.0", - "@wordpress/rich-text": "7.17.0", + "@wordpress/api-fetch": "7.18.0", + "@wordpress/block-editor": "14.13.0", + "@wordpress/blocks": "14.7.0", + "@wordpress/components": "29.4.0", + "@wordpress/compose": "7.18.0", + "@wordpress/core-data": "7.18.0", + "@wordpress/data": "10.18.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.18.0", + "@wordpress/dom-ready": "4.18.0", + "@wordpress/e2e-test-utils-playwright": "^1.18.0", + "@wordpress/editor": "14.18.0", + "@wordpress/element": "6.18.0", + "@wordpress/env": "10.18.0", + "@wordpress/hooks": "4.18.0", + "@wordpress/i18n": "5.18.0", + "@wordpress/icons": "10.18.0", + "@wordpress/interactivity": "6.18.0", + "@wordpress/notices": "5.18.0", + "@wordpress/plugins": "7.18.0", + "@wordpress/primitives": "4.18.0", + "@wordpress/rich-text": "7.18.0", "@wordpress/scripts": "30.1.0", - "@wordpress/server-side-render": "5.17.0", - "@wordpress/url": "4.17.0", + "@wordpress/server-side-render": "5.18.0", + "@wordpress/url": "4.18.0", "eslint": "8.57.1", "fork-ts-checker-webpack-plugin": "9.0.2", - "happy-dom": "16.8.1", + "happy-dom": "17.1.0", "husky": "9.1.7", "lint-staged": "15.4.3", "prettier": "npm:wp-prettier@2.8.5", @@ -112,7 +112,7 @@ }, "dependencies": { "@automattic/calypso-analytics": "^1.1.3", - "@wordpress/dataviews": "^4.13.0", + "@wordpress/dataviews": "^4.14.0", "react-syntax-highlighter": "^15.6.1" } } diff --git a/remote-data-blocks.php b/remote-data-blocks.php index 996feaf1..d65e5794 100644 --- a/remote-data-blocks.php +++ b/remote-data-blocks.php @@ -33,8 +33,8 @@ Editor\BlockManagement\ConfigRegistry::init(); Editor\PatternEditor\PatternEditor::init(); -// Analytics -Analytics\TracksAnalytics::init( new Analytics\EnvironmentConfig() ); +// Telemetry +Telemetry\TracksTelemetry::init( new Telemetry\EnvironmentConfig() ); // Example API ExampleApi\ExampleApi::init(); @@ -46,7 +46,7 @@ Integrations\Airtable\AirtableIntegration::init(); Integrations\Google\Sheets\GoogleSheetsIntegration::init(); Integrations\Shopify\ShopifyIntegration::init(); -Integrations\SalesforceB2C\SalesforceB2CIntegration::init(); +Integrations\SalesforceD2C\SalesforceD2CIntegration::init(); Integrations\VipBlockDataApi\VipBlockDataApi::init(); // REST endpoints diff --git a/src/blocks/remote-data-container/components/BlockBindingControls.tsx b/src/blocks/remote-data-container/components/BlockBindingControls.tsx index 9a4f410d..3360c15f 100644 --- a/src/blocks/remote-data-container/components/BlockBindingControls.tsx +++ b/src/blocks/remote-data-container/components/BlockBindingControls.tsx @@ -62,7 +62,7 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { function updateFieldBinding( target: string, field: string ): void { if ( ! field ) { removeBinding( target ); - sendTracksEvent( 'remotedatablocks_remote_data_container_actions', { + sendTracksEvent( 'remote_data_container_actions', { action: 'remove_binding', data_source_type: getBlockDataSourceType( remoteDataName ), block_target_attribute: target, @@ -73,7 +73,7 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { const args = attributes.metadata?.bindings?.[ target ]?.args ?? {}; updateBinding( target, { ...args, field } ); - sendTracksEvent( 'remotedatablocks_remote_data_container_actions', { + sendTracksEvent( 'remote_data_container_actions', { action: 'update_binding', data_source_type: getBlockDataSourceType( remoteDataName ), remote_data_field: field, @@ -91,7 +91,7 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { ? Object.entries( availableBindings ).find( ( [ key ] ) => key === contentField )?.[ 1 ]?.name : undefined; updateBinding( 'content', { ...contentArgs, field: contentField, label } ); - sendTracksEvent( 'remotedatablocks_remote_data_container_actions', { + sendTracksEvent( 'remote_data_container_actions', { action: showLabel ? 'show_label' : 'hide_label', data_source_type: getBlockDataSourceType( remoteDataName ), } ); diff --git a/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeButton.tsx b/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeButton.tsx index 13456f4f..5d67113f 100644 --- a/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeButton.tsx +++ b/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeButton.tsx @@ -66,7 +66,7 @@ export function FieldShortcodeButton( props: WPFormatEditProps ) { updateOrInsertField( data, fieldValue ); setShowUI( false ); onFocus(); - sendTracksEvent( 'remotedatablocks_field_shortcode', { + sendTracksEvent( 'field_shortcode', { action: data.action, data_source_type: getBlockDataSourceType( data.remoteData?.blockName ), selection_path: data.selectionPath, @@ -75,7 +75,7 @@ export function FieldShortcodeButton( props: WPFormatEditProps ) { const resetField = ( blockName?: string ): void => { updateOrInsertField( null, 'Unbound field' ); - sendTracksEvent( 'remotedatablocks_field_shortcode', { + sendTracksEvent( 'field_shortcode', { action: 'reset_field_shortcode', data_source_type: getBlockDataSourceType( blockName ), } ); diff --git a/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx b/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx index 4a6d68dc..39026440 100644 --- a/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx +++ b/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx @@ -57,7 +57,6 @@ export function FieldShortcodeSelectNew( props: FieldShortcodeSelectNewProps ) { key={ blockConfig.name } blockName={ blockConfig.name } headerImage={ compatibleSelector.image_url } - inputVariables={ compatibleSelector.inputs } onSelectField={ onSelectField } queryKey={ compatibleSelector.query_key } renderTrigger={ ( { onClick } ) => ( diff --git a/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx b/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx index 0e4b1cea..1bd55b0a 100644 --- a/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx +++ b/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx @@ -1,5 +1,4 @@ import { Button, Modal } from '@wordpress/components'; -import { useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { ItemList } from '@/blocks/remote-data-container/components/item-list/ItemList'; @@ -16,7 +15,6 @@ interface DataViewsModalProps { className?: string; blockName: string; headerImage?: string; - inputVariables: InputVariable[]; onSelect?: ( data: RemoteDataQueryInput ) => void; onSelectField?: ( data: FieldSelection, fieldValue: string ) => void; queryKey: string; @@ -25,16 +23,7 @@ interface DataViewsModalProps { } export const DataViewsModal: React.FC< DataViewsModalProps > = props => { - const { - className, - blockName, - inputVariables, - onSelect, - onSelectField, - queryKey, - renderTrigger, - title, - } = props; + const { className, blockName, onSelect, onSelectField, queryKey, renderTrigger, title } = props; const blockConfig = getBlockConfig( blockName ); const availableBindings = getBlockAvailableBindings( blockName ); @@ -42,7 +31,6 @@ export const DataViewsModal: React.FC< DataViewsModalProps > = props => { const { close, isOpen, open } = useModalState(); const { data, - fetch, loading, page, searchInput, @@ -51,19 +39,11 @@ export const DataViewsModal: React.FC< DataViewsModalProps > = props => { supportsSearch, totalItems, totalPages, - } = useRemoteData( { - blockName, - inputVariables, - queryKey, - } ); - - useEffect( () => { - void fetch( {} ); - }, [] ); + } = useRemoteData( { blockName, fetchOnMount: true, queryKey } ); function onSelectItem( input: RemoteDataQueryInput ): void { onSelect?.( input ); - sendTracksEvent( 'remotedatablocks_add_block', { + sendTracksEvent( 'add_block', { action: 'select_item', selected_option: 'search_from_list', data_source_type: getBlockDataSourceType( blockName ), diff --git a/src/blocks/remote-data-container/components/modals/InputModal.tsx b/src/blocks/remote-data-container/components/modals/InputModal.tsx index ebd406b9..ffa04280 100644 --- a/src/blocks/remote-data-container/components/modals/InputModal.tsx +++ b/src/blocks/remote-data-container/components/modals/InputModal.tsx @@ -36,7 +36,7 @@ export function InputModal( props: InputModalProps ) { function onSelectItem(): void { props.onSelect( inputState ); close(); - sendTracksEvent( 'remotedatablocks_add_block', { + sendTracksEvent( 'add_block', { action: 'select_item', selected_option: 'manual_input', data_source_type: getBlockDataSourceType( props.blockName ), diff --git a/src/blocks/remote-data-container/components/panels/DataPanel.tsx b/src/blocks/remote-data-container/components/panels/DataPanel.tsx index 9476277b..f812b1fe 100644 --- a/src/blocks/remote-data-container/components/panels/DataPanel.tsx +++ b/src/blocks/remote-data-container/components/panels/DataPanel.tsx @@ -23,7 +23,7 @@ export function DataPanel( props: DataPanelProps ) { function onRefreshRemoteData(): void { refreshRemoteData(); - sendTracksEvent( 'remotedatablocks_remote_data_container_actions', { + sendTracksEvent( 'remote_data_container_actions', { action: 'refresh_block_data', data_source_type: getBlockDataSourceType( remoteData.blockName ), } ); @@ -32,7 +32,7 @@ export function DataPanel( props: DataPanelProps ) { function resetBlock(): void { resetRemoteData(); setResetConfirmOpen( false ); - sendTracksEvent( 'remotedatablocks_remote_data_container_actions', { + sendTracksEvent( 'remote_data_container_actions', { action: 'reset_block_data', data_source_type: getBlockDataSourceType( remoteData.blockName ), } ); diff --git a/src/blocks/remote-data-container/components/panels/OverridesPanel.tsx b/src/blocks/remote-data-container/components/panels/OverridesPanel.tsx index 64547ce5..d6b4daca 100644 --- a/src/blocks/remote-data-container/components/panels/OverridesPanel.tsx +++ b/src/blocks/remote-data-container/components/panels/OverridesPanel.tsx @@ -23,7 +23,7 @@ export function OverridesPanel( props: OverridesPanelProps ) { function updateOverrides( overrideName: string, enabled: boolean ) { if ( enabled ) { enabledOverrides.add( overrideName ); - sendTracksEvent( 'remotedatablocks_remote_data_container_override', { + sendTracksEvent( 'remote_data_container_override', { data_source_type: getBlockDataSourceType( remoteData.blockName ), override_type: 'unknown', // We no longer know the override type since the implementation is delegated. override_target: 'unknown', diff --git a/src/blocks/remote-data-container/components/pattern-selection/PatternSelection.tsx b/src/blocks/remote-data-container/components/pattern-selection/PatternSelection.tsx index 91e530d5..91afbe56 100644 --- a/src/blocks/remote-data-container/components/pattern-selection/PatternSelection.tsx +++ b/src/blocks/remote-data-container/components/pattern-selection/PatternSelection.tsx @@ -21,7 +21,7 @@ export function PatternSelection( props: PatternSelectionProps ) { function onClickPattern( pattern: BlockPattern ) { props.onSelectPattern( pattern ); setShowModal( false ); - sendTracksEvent( 'remotedatablocks_add_block', { + sendTracksEvent( 'add_block', { action: 'select_pattern', selected_option: 'select_from_list', data_source_type: getBlockDataSourceType( props.blockName ), @@ -34,7 +34,7 @@ export function PatternSelection( props: PatternSelectionProps ) { function onClickManualEdit(): void { props.onCancel(); - sendTracksEvent( 'remotedatablocks_add_block', { + sendTracksEvent( 'add_block', { action: 'select_pattern', selected_option: 'manual_edit', data_source_type: getBlockDataSourceType( props.blockName ), diff --git a/src/blocks/remote-data-container/components/popovers/InputPopover.tsx b/src/blocks/remote-data-container/components/popovers/InputPopover.tsx index 9f50f8a1..35d1afea 100644 --- a/src/blocks/remote-data-container/components/popovers/InputPopover.tsx +++ b/src/blocks/remote-data-container/components/popovers/InputPopover.tsx @@ -39,7 +39,7 @@ export function InputPopover( props: InputPopoverProps ) { function onSelectItem(): void { onSelect( inputState ); close(); - sendTracksEvent( 'remotedatablocks_add_block', { + sendTracksEvent( 'add_block', { action: 'select_item', selected_option: 'manual_input', data_source_type: getBlockDataSourceType( props.blockName ), diff --git a/src/blocks/remote-data-container/hooks/useRemoteData.ts b/src/blocks/remote-data-container/hooks/useRemoteData.ts index 1b88da27..929818da 100644 --- a/src/blocks/remote-data-container/hooks/useRemoteData.ts +++ b/src/blocks/remote-data-container/hooks/useRemoteData.ts @@ -4,6 +4,14 @@ import { useEffect, useState } from '@wordpress/element'; import { REMOTE_DATA_REST_API_URL } from '@/blocks/remote-data-container/config/constants'; import { usePaginationVariables } from '@/blocks/remote-data-container/hooks/usePaginationVariables'; import { useSearchVariables } from '@/blocks/remote-data-container/hooks/useSearchVariables'; +import { isQueryInputValid, validateQueryInput } from '@/utils/input-validation'; +import { getBlockConfig } from '@/utils/localized-block-data'; + +export class RemoteDataFetchError extends Error { + constructor( message: string, public cause: unknown ) { + super( message ); + } +} async function fetchRemoteData( requestData: RemoteDataApiRequest ): Promise< RemoteData | null > { const { body } = await apiFetch< RemoteDataApiResponse >( { @@ -41,6 +49,7 @@ async function fetchRemoteData( requestData: RemoteDataApiRequest ): Promise< Re interface UseRemoteData { data?: RemoteData; + error?: Error; fetch: ( queryInput: RemoteDataQueryInput ) => Promise< void >; hasNextPage: boolean; hasPreviousPage: boolean; @@ -48,7 +57,6 @@ interface UseRemoteData { page: number; perPage?: number; reset: () => void; - searchAllowsEmptyInput: boolean; searchInput: string; setPage: ( page: number ) => void; setPerPage: ( perPage: number ) => void; @@ -68,10 +76,10 @@ interface UseRemoteDataInput { enabledOverrides?: string[]; externallyManagedRemoteData?: RemoteData; externallyManagedUpdateRemoteData?: ( remoteData?: RemoteData ) => void; + fetchOnMount?: boolean; initialPage?: number; initialPerPage?: number; initialSearchInput?: string; - inputVariables?: InputVariable[]; onSuccess?: () => void; queryKey: string; } @@ -89,20 +97,33 @@ export function useRemoteData( { enabledOverrides = [], externallyManagedRemoteData, externallyManagedUpdateRemoteData, + fetchOnMount = false, initialPage, initialPerPage, initialSearchInput, - inputVariables = [], onSuccess, queryKey, }: UseRemoteDataInput ): UseRemoteData { const [ data, setData ] = useState< RemoteData >(); + const [ error, setError ] = useState< Error >(); const [ loading, setLoading ] = useState< boolean >( false ); const resolvedData = externallyManagedRemoteData ?? data; const resolvedUpdater = externallyManagedUpdateRemoteData ?? setData; const hasResolvedData = Boolean( resolvedData ); + const blockConfig = getBlockConfig( blockName ); + const query = blockConfig?.selectors?.find( selector => selector.query_key === queryKey ); + + if ( ! query ) { + // Here we intentionally throw an error instead of calling setError, because + // this indicates a misconfiguration somewhere in our code, not a runtime / + // query error. + throw new Error( `Query not found for block "${ blockName }" and key "${ queryKey }".` ); + } + + const inputVariables = query.inputs; + const { onFetch: onFetchForPagination, page, @@ -117,34 +138,81 @@ export function useRemoteData( { initialPerPage, inputVariables, } ); - const { searchQueryInput, searchAllowsEmptyInput, searchInput, setSearchInput, supportsSearch } = + const { hasSearchInput, searchQueryInput, searchInput, setSearchInput, supportsSearch } = useSearchVariables( { initialSearchInput, inputVariables, } ); + const managedQueryInput = { ...paginationQueryInput, ...searchQueryInput }; + + // Search and pagination are "managed" input variables (this hook manages their + // state), so we should refetch if those variables change. If the query fails, + // the resulting error will be returned by this hook if there is valid search + // input, then we should consider the query and can be inspected by the caller + // to determine if or how to surface it to the user. + // + // If we add additional managed input variables (like filters), we'll need to + // include them here. + // + // We only want to refetch if there was a previous successful fetch. + const shouldFetchForManagedVariables = ! error && ( hasResolvedData || hasSearchInput ); + const shouldClearResolvedData = hasResolvedData && supportsSearch && ! hasSearchInput; useEffect( () => { - if ( ! hasResolvedData ) { + if ( shouldClearResolvedData ) { + resolvedUpdater( undefined ); + return; + } + + if ( ! shouldFetchForManagedVariables ) { return; } void fetch( resolvedData?.queryInput ?? {} ); - }, [ hasResolvedData, page, perPage, searchInput ] ); + }, [ shouldClearResolvedData, shouldFetchForManagedVariables, page, perPage, searchInput ] ); + + // Separately, some callers request an "optimistic" initial fetch. An example + // would be DataViewsModal, which will display an initial list of items to + // choose from if the query supports it. This is implemented in a separate + // effect to avoid entangling the logic of initial fetch and refetch. + // + // This fetch may fail if the query input is invalid or incomplete, but as an + // "optimistic" fetch, we don't want to surface that error to the user. So we + // do a pre-validation and bail if we see that validation will not pass. + // + // The dependency array is empty because we only want to run this effect once. + useEffect( () => { + if ( ! fetchOnMount || ! isQueryInputValid( managedQueryInput, inputVariables ) ) { + return; + } - async function fetch( queryInput: RemoteDataQueryInput ): Promise< void > { - setLoading( true ); + void fetch( {} ); + }, [] ); + async function fetch( queryInput: RemoteDataQueryInput ): Promise< void > { const requestData: RemoteDataApiRequest = { block_name: blockName, query_key: queryKey, query_input: { ...queryInput, - ...paginationQueryInput, - ...searchQueryInput, + ...managedQueryInput, }, }; - const remoteData = await fetchRemoteData( requestData ).catch( () => null ); + try { + validateQueryInput( requestData.query_input, inputVariables ); + } catch ( err: unknown ) { + resolvedUpdater( undefined ); + setError( new RemoteDataFetchError( 'Query input is invalid', err ) ); + return; + } + + setLoading( true ); + + const remoteData = await fetchRemoteData( requestData ).catch( ( err: unknown ) => { + setError( new RemoteDataFetchError( 'Request for remote data failed', err ) ); + return null; + } ); if ( ! remoteData ) { resolvedUpdater( undefined ); @@ -160,10 +228,13 @@ export function useRemoteData( { function reset(): void { resolvedUpdater( undefined ); + setError( undefined ); + setLoading( false ); } return { data: resolvedData, + error, fetch, hasNextPage: totalPages ? page < totalPages : supportsPagination, hasPreviousPage: page > 1, @@ -171,7 +242,6 @@ export function useRemoteData( { page, perPage, reset, - searchAllowsEmptyInput, searchInput, setSearchInput, supportsPagination, diff --git a/src/blocks/remote-data-container/hooks/useSearchVariables.ts b/src/blocks/remote-data-container/hooks/useSearchVariables.ts index 074b2005..643b98f4 100644 --- a/src/blocks/remote-data-container/hooks/useSearchVariables.ts +++ b/src/blocks/remote-data-container/hooks/useSearchVariables.ts @@ -2,7 +2,7 @@ import { SEARCH_INPUT_VARIABLE_TYPE } from '@/blocks/remote-data-container/confi import { useDebouncedState } from '@/hooks/useDebouncedState'; interface UseSearchVariables { - searchAllowsEmptyInput: boolean; + hasSearchInput: boolean; searchInput: string; searchQueryInput: RemoteDataQueryInput; setSearchInput: ( searchInput: string ) => void; @@ -28,13 +28,14 @@ export function useSearchVariables( { const inputVariable = inputVariables?.find( input => input.type === SEARCH_INPUT_VARIABLE_TYPE ); const supportsSearch = Boolean( inputVariable ); const searchAllowsEmptyInput = supportsSearch && ! inputVariable?.required; - const hasSearchInput = supportsSearch && ( searchInput || searchAllowsEmptyInput ); + const hasSearchInput = supportsSearch && Boolean( searchInput || searchAllowsEmptyInput ); return { - searchAllowsEmptyInput, + hasSearchInput, searchInput, - searchQueryInput: - hasSearchInput && inputVariable ? { [ inputVariable.slug ]: searchInput } : {}, + searchQueryInput: supportsSearch + ? { [ inputVariable?.slug ?? '' ]: hasSearchInput ? searchInput : null } + : {}, setSearchInput: supportsSearch ? setSearchInput : () => {}, supportsSearch, }; diff --git a/src/blocks/remote-data-container/utils/tracks.spec.ts b/src/blocks/remote-data-container/utils/tracks.spec.ts index 57a3b264..274c3643 100644 --- a/src/blocks/remote-data-container/utils/tracks.spec.ts +++ b/src/blocks/remote-data-container/utils/tracks.spec.ts @@ -39,13 +39,13 @@ describe( 'sendTracksEvent', () => { it( 'should not record event if Tracks global properties is not defined', () => { window.REMOTE_DATA_BLOCKS = { config: {}, rest_url: '', tracks_global_properties: undefined }; - sendTracksEvent( 'remotedatablocks_field_shortcode', { action: 'value' } ); + sendTracksEvent( 'field_shortcode', { action: 'value' } ); expect( recordTracksEvent ).not.toHaveBeenCalled(); } ); it( 'should not track if vip_env is local', () => { - sendTracksEvent( 'remotedatablocks_field_shortcode', { action: 'value' } ); + sendTracksEvent( 'field_shortcode', { action: 'value' } ); expect( recordTracksEvent ).not.toHaveBeenCalled(); } ); @@ -56,7 +56,7 @@ describe( 'sendTracksEvent', () => { vip_env: 'production', } ); - sendTracksEvent( 'remotedatablocks_field_shortcode', { action: 'actionName' } ); + sendTracksEvent( 'field_shortcode', { action: 'actionName' } ); expect( recordTracksEvent ).toHaveBeenCalledTimes( 1 ); expect( recordTracksEvent ).toHaveBeenCalledWith( 'remotedatablocks_field_shortcode', { diff --git a/src/blocks/remote-data-container/utils/tracks.ts b/src/blocks/remote-data-container/utils/tracks.ts index 70c86c3c..5f015fe5 100644 --- a/src/blocks/remote-data-container/utils/tracks.ts +++ b/src/blocks/remote-data-container/utils/tracks.ts @@ -3,33 +3,41 @@ import { recordTracksEvent } from '@automattic/calypso-analytics'; import { getTracksGlobalProperties } from '@/utils/localized-block-data'; interface TRACKS_EVENTS { - remotedatablocks_remote_data_container_actions: { + remote_data_container_actions: { action: string; block_target_attribute?: string; data_source_type: string; remote_data_field?: string; }; - remotedatablocks_field_shortcode: { + field_shortcode: { action: string; data_source_type?: string; selection_path?: string; }; - remotedatablocks_add_block: { + add_block: { action: string; selected_option: string; data_source_type: string; }; - remotedatablocks_remote_data_container_override: { + remote_data_container_override: { data_source_type: string; override_type?: string; override_target?: string; }; - remotedatablocks_associate_block_type_to_pattern: { + associate_block_type_to_pattern: { data_source_type: string; is_pattern_synced: boolean; }; + view_data_sources: { + total_data_sources_count: number; + code_configured_data_sources_count: number; + ui_configured_data_sources_count: number; + constants_configured_data_sources_count: number; + }; } +const TRACKS_EVENT_PREFIX = 'remotedatablocks_'; + /** * Send a tracks event with the given name and properties. */ @@ -49,5 +57,5 @@ export function sendTracksEvent< K extends keyof TRACKS_EVENTS >( return; } - recordTracksEvent( eventName, { ...globalProps, ...eventProps } ); + recordTracksEvent( `${ TRACKS_EVENT_PREFIX }${ eventName }`, { ...globalProps, ...eventProps } ); } diff --git a/src/data-sources/DataSourceList.tsx b/src/data-sources/DataSourceList.tsx index 075db6e4..9a914736 100644 --- a/src/data-sources/DataSourceList.tsx +++ b/src/data-sources/DataSourceList.tsx @@ -17,19 +17,24 @@ import { __, sprintf } from '@wordpress/i18n'; import { info } from '@wordpress/icons'; import CodeSnippet from './components/CodeSnippet'; -import { SUPPORTED_SERVICES, SUPPORTED_SERVICES_LABELS } from './constants'; import { BaseModal } from '@/blocks/remote-data-container/components/modals/BaseModal'; import { useModalState } from '@/blocks/remote-data-container/hooks/useModalState'; import DataSourceMetaTags from '@/data-sources/DataSourceMetaTags'; +import { + SUPPORTED_SERVICES, + SUPPORTED_SERVICES_LABELS, + ConfigSource, +} from '@/data-sources/constants'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { DataSourceConfig } from '@/data-sources/types'; import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; -import './DataSourceList.scss'; import { AirtableIcon } from '@/settings/icons/AirtableIcon'; import { GoogleSheetsIcon } from '@/settings/icons/GoogleSheetsIcon'; import HttpIcon from '@/settings/icons/HttpIcon'; import { ShopifyIcon } from '@/settings/icons/ShopifyIcon'; +import './DataSourceList.scss'; + const DataSourceList = () => { const { dataSources, @@ -148,15 +153,17 @@ const DataSourceList = () => { table: {}, }; + const isItemEligibleForActions = ( item: DataSourceConfig ) => { + return item.config_source === ConfigSource.STORAGE; + }; + const actions: Action< DataSourceConfig >[] = [ { id: 'edit', label: __( 'Edit', 'remote-data-blocks' ), icon: 'edit', isPrimary: true, - isEligible: ( item: DataSourceConfig ) => { - return Boolean( item?.uuid ); - }, + isEligible: isItemEligibleForActions, callback: ( [ item ]: DataSourceConfig[] ) => { if ( item?.uuid ) { onEditDataSource( item.uuid ); @@ -167,9 +174,7 @@ const DataSourceList = () => { id: 'copy', label: __( 'Copy UUID', 'remote-data-blocks' ), icon: 'copy', - isEligible: ( item: DataSourceConfig ) => { - return Boolean( item?.uuid ); - }, + isEligible: isItemEligibleForActions, callback: ( [ item ]: DataSourceConfig[] ) => { if ( item && item.uuid ) { navigator.clipboard @@ -191,9 +196,7 @@ const DataSourceList = () => { label: __( 'Delete', 'remote-data-blocks' ), icon: 'trash', isDestructive: true, - isEligible: ( item: DataSourceConfig ) => { - return Boolean( item?.uuid ); - }, + isEligible: isItemEligibleForActions, callback: ( items: DataSourceConfig[] ) => { if ( items.length === 1 ) { if ( items[ 0 ] ) { @@ -208,9 +211,7 @@ const DataSourceList = () => { { id: 'duplicate', label: __( 'Duplicate', 'remote-data-blocks' ), - isEligible: ( item: DataSourceConfig ) => { - return Boolean( item?.uuid ); - }, + isEligible: isItemEligibleForActions, callback: ( [ item ]: DataSourceConfig[] ) => { if ( item ) { const duplicatedSource = { @@ -240,7 +241,7 @@ const DataSourceList = () => { { id: 'view-code', label: __( 'View Code', 'remote-data-blocks' ), - isEligible: ( item: DataSourceConfig ) => Boolean( item?.uuid ), + isEligible: isItemEligibleForActions, callback: ( [ item ]: DataSourceConfig[] ) => { if ( item?.uuid ) { setCurrentSource( item ); @@ -265,7 +266,7 @@ const DataSourceList = () => { icon={ info } label={ __( 'No data source found.', 'remote-data-blocks' ) } instructions={ __( - 'Use the “Connect New” button to add a data source.', + 'Use the "Connect New" button to add a data source.', 'remote-data-blocks' ) } /> diff --git a/src/data-sources/DataSourceMetaTags.tsx b/src/data-sources/DataSourceMetaTags.tsx index 934edf28..7ba8c66c 100644 --- a/src/data-sources/DataSourceMetaTags.tsx +++ b/src/data-sources/DataSourceMetaTags.tsx @@ -2,6 +2,7 @@ import { Icon, Tooltip } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { chevronRightSmall } from '@wordpress/icons'; +import { ConfigSource } from '@/data-sources/constants'; import { DataSourceConfig } from '@/data-sources/types'; import './DataSourceList.scss'; @@ -61,10 +62,19 @@ const CodeBadge = () => { ); }; +const ConstantsBadge = () => { + return ( + + Constants + + ); +}; + const DataSourceMetaTags = ( props: DataSourceMetaTagsProps ) => { return ( <> - { ! props.source.uuid && } + { props.source.config_source === ConfigSource.CODE && } + { props.source.config_source === ConfigSource.CONSTANTS && } ); diff --git a/src/data-sources/DataSourceSettings.tsx b/src/data-sources/DataSourceSettings.tsx index dc1abf62..2725fbf6 100644 --- a/src/data-sources/DataSourceSettings.tsx +++ b/src/data-sources/DataSourceSettings.tsx @@ -4,7 +4,7 @@ import { AirtableSettings } from '@/data-sources/airtable/AirtableSettings'; import { GoogleSheetsSettings } from '@/data-sources/google-sheets/GoogleSheetsSettings'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { HttpSettings } from '@/data-sources/http/HttpSettings'; -import { SalesforceB2CSettings } from '@/data-sources/salesforce-b2c/SalesforceB2CSettings'; +import { SalesforceD2CSettings } from '@/data-sources/salesforce-d2c/SalesforceD2CSettings'; import { ShopifySettings } from '@/data-sources/shopify/ShopifySettings'; import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; @@ -33,8 +33,8 @@ const DataSourceEditSettings = ( { uuid }: DataSourceEditSettings ) => { return ; } else if ( 'shopify' === dataSource.service ) { return ; - } else if ( 'salesforce-b2c' === dataSource.service ) { - return ; + } else if ( 'salesforce-d2c' === dataSource.service ) { + return ; } return <>{ __( 'Service not (yet) supported.', 'remote-data-blocks' ) }; @@ -53,8 +53,8 @@ const DataSourceSettings = () => { return ; } else if ( 'shopify' === service ) { return ; - } else if ( 'salesforce-b2c' === service ) { - return ; + } else if ( 'salesforce-d2c' === service ) { + return ; } return <>{ __( 'Service not (yet) supported.', 'remote-data-blocks' ) }; } diff --git a/src/data-sources/airtable/AirtableSettings.tsx b/src/data-sources/airtable/AirtableSettings.tsx index a04a5382..d6c8b845 100644 --- a/src/data-sources/airtable/AirtableSettings.tsx +++ b/src/data-sources/airtable/AirtableSettings.tsx @@ -7,6 +7,7 @@ import { getAirtableOutputQueryMappingValues } from '@/data-sources/airtable/uti import { DataSourceForm } from '@/data-sources/components/DataSourceForm'; import { FieldsSelection } from '@/data-sources/components/FieldsSelection'; import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; +import { ConfigSource } from '@/data-sources/constants'; import { useAirtableApiBases, useAirtableApiTables, @@ -76,6 +77,7 @@ export const AirtableSettings = ( { service: 'airtable', service_config: validState, uuid: uuid ?? null, + config_source: ConfigSource.STORAGE, }; return onSave( airtableConfig, mode ); @@ -214,7 +216,7 @@ export const AirtableSettings = ( { diff --git a/src/data-sources/api-clients/auth.ts b/src/data-sources/api-clients/auth.ts index d0503ab5..6052bbf4 100644 --- a/src/data-sources/api-clients/auth.ts +++ b/src/data-sources/api-clients/auth.ts @@ -1,6 +1,7 @@ import apiFetch from '@wordpress/api-fetch'; import { REST_BASE_AUTH } from '@/data-sources/constants'; +import { SalesforceD2CWebStoreRecord, SalesforceD2CWebStoresResponse } from '@/data-sources/types'; import { GoogleServiceAccountKey } from '@/types/google'; export async function getGoogleAuthTokenFromServiceAccount( @@ -21,3 +22,23 @@ export async function getGoogleAuthTokenFromServiceAccount( return response.token; } + +export async function getSalesforceD2CStores( + domain: string, + clientId: string, + clientSecret: string +): Promise< SalesforceD2CWebStoreRecord[] > { + const requestBody = { + domain, + clientId, + clientSecret, + }; + + const response = await apiFetch< SalesforceD2CWebStoresResponse >( { + path: `${ REST_BASE_AUTH }/salesforce-d2c/stores`, + method: 'POST', + data: requestBody, + } ); + + return response.webstores; +} diff --git a/src/data-sources/api-clients/google.ts b/src/data-sources/api-clients/google.ts index 1544eab2..d8b34092 100644 --- a/src/data-sources/api-clients/google.ts +++ b/src/data-sources/api-clients/google.ts @@ -1,13 +1,13 @@ import { __, sprintf } from '@wordpress/i18n'; import { - GoogleSpreadsheet, - GoogleDriveFileList, GoogleDriveFile, + GoogleDriveFileList, + GoogleSheetIdName, GoogleSheetsValueRange, - GoogleSpreadsheetFields, GoogleSheetWithFields, - GoogleSheetIdName, + GoogleSpreadsheet, + GoogleSpreadsheetFields, } from '@/types/google'; import { SelectOption } from '@/types/input'; diff --git a/src/data-sources/components/AddDataSourceDropdown.tsx b/src/data-sources/components/AddDataSourceDropdown.tsx index 7f5e1d31..36b5ce1c 100644 --- a/src/data-sources/components/AddDataSourceDropdown.tsx +++ b/src/data-sources/components/AddDataSourceDropdown.tsx @@ -1,12 +1,12 @@ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { SUPPORTED_SERVICES_LABELS } from '../constants'; +import { SUPPORTED_SERVICES_LABELS } from '@/data-sources/constants'; import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; import { AirtableIcon } from '@/settings/icons/AirtableIcon'; import { GoogleSheetsIcon } from '@/settings/icons/GoogleSheetsIcon'; import HttpIcon from '@/settings/icons/HttpIcon'; -import SalesforceCommerceB2CIcon from '@/settings/icons/SalesforceCommerceB2CIcon'; +import SalesforceCommerceD2CIcon from '@/settings/icons/SalesforceCommerceD2CIcon'; import { ShopifyIcon } from '@/settings/icons/ShopifyIcon'; import '../DataSourceList.scss'; @@ -54,9 +54,9 @@ export const AddDataSourceDropdown = () => { value: 'shopify', }, { - icon: SalesforceCommerceB2CIcon, - label: SUPPORTED_SERVICES_LABELS[ 'salesforce-b2c' ], - value: 'salesforce-b2c', + icon: SalesforceCommerceD2CIcon, + label: SUPPORTED_SERVICES_LABELS[ 'salesforce-d2c' ], + value: 'salesforce-d2c', }, { icon: HttpIcon, diff --git a/src/data-sources/constants.ts b/src/data-sources/constants.ts index eb100b06..c9f6a33d 100644 --- a/src/data-sources/constants.ts +++ b/src/data-sources/constants.ts @@ -1,6 +1,6 @@ import { __ } from '@wordpress/i18n'; -import { HttpAuthTypes, HttpApiKeyDestination } from '@/data-sources/http/types'; +import { HttpApiKeyDestination, HttpAuthTypes } from '@/data-sources/http/types'; import { SelectOption } from '@/types/input'; export const SUPPORTED_SERVICES = [ @@ -8,7 +8,7 @@ export const SUPPORTED_SERVICES = [ 'example-api', 'generic-http', 'google-sheets', - 'salesforce-b2c', + 'salesforce-d2c', 'shopify', ] as const; export const SUPPORTED_SERVICES_LABELS: Record< ( typeof SUPPORTED_SERVICES )[ number ], string > = @@ -17,7 +17,7 @@ export const SUPPORTED_SERVICES_LABELS: Record< ( typeof SUPPORTED_SERVICES )[ n 'example-api': __( 'Conference Events Example API', 'remote-data-blocks' ), 'generic-http': __( 'HTTP', 'remote-data-blocks' ), 'google-sheets': __( 'Google Sheets', 'remote-data-blocks' ), - 'salesforce-b2c': __( 'Salesforce Commerce B2C', 'remote-data-blocks' ), + 'salesforce-d2c': __( 'Salesforce D2C', 'remote-data-blocks' ), shopify: __( 'Shopify', 'remote-data-blocks' ), } as const; export const OPTIONS_PAGE_SLUG = 'remote-data-blocks-settings'; @@ -45,3 +45,9 @@ export const HTTP_SOURCE_ADD_TO_SELECT_OPTIONS: SelectOption< HttpApiKeyDestinat { label: __( 'Header', 'remote-data-blocks' ), value: 'header' }, { label: __( 'Query Params', 'remote-data-blocks' ), value: 'queryparams' }, ]; + +export enum ConfigSource { + CODE = 'code', + STORAGE = 'storage', + CONSTANTS = 'constant', +} diff --git a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx index af730392..1867010a 100644 --- a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx +++ b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx @@ -1,22 +1,22 @@ -import { TextareaControl, SelectControl } from '@wordpress/components'; +import { SelectControl, TextareaControl } from '@wordpress/components'; import { useEffect, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '@/data-sources/components/DataSourceForm'; import { FieldsSelection } from '@/data-sources/components/FieldsSelection'; -import { GOOGLE_SHEETS_API_SCOPES } from '@/data-sources/constants'; +import { GOOGLE_SHEETS_API_SCOPES, ConfigSource } from '@/data-sources/constants'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { - useGoogleSpreadsheetsOptions, useGoogleSheetsWithFields, + useGoogleSpreadsheetsOptions, } from '@/data-sources/hooks/useGoogleApi'; import { useGoogleAuth } from '@/data-sources/hooks/useGoogleAuth'; import { + DataSourceQueryMappingValue, GoogleSheetsConfig, GoogleSheetsServiceConfig, GoogleSheetsSheetConfig, SettingsComponentProps, - DataSourceQueryMappingValue, } from '@/data-sources/types'; import { getConnectionMessage } from '@/data-sources/utils'; import { useForm, ValidationRules } from '@/hooks/useForm'; @@ -97,6 +97,7 @@ export const GoogleSheetsSettings = ( { service: 'google-sheets', service_config: validState, uuid: uuid ?? null, + config_source: ConfigSource.STORAGE, }; return onSave( data, mode ); @@ -274,7 +275,7 @@ export const GoogleSheetsSettings = ( { ); diff --git a/src/data-sources/hooks/useSalesforceD2CAuth.ts b/src/data-sources/hooks/useSalesforceD2CAuth.ts new file mode 100644 index 00000000..4d7db38b --- /dev/null +++ b/src/data-sources/hooks/useSalesforceD2CAuth.ts @@ -0,0 +1,34 @@ +import { useDebounce } from '@wordpress/compose'; +import { useCallback, useEffect } from '@wordpress/element'; + +import { getSalesforceD2CStores } from '@/data-sources/api-clients/auth'; +import { useQuery } from '@/hooks/useQuery'; + +export const useSalesforceD2CAuth = ( domain: string, clientId: string, clientSecret: string ) => { + const queryFn = useCallback( async () => { + if ( ! domain || ! clientId || ! clientSecret ) { + return null; + } + + // Only proceed if the domain is valid, which is that it should not be a full url. + const invalidDomainPattern = /^(https?:\/\/|www\.)|[\\/\\]/; + if ( invalidDomainPattern.test( domain ) ) { + throw new Error( 'Invalid domain provided' ); + } + + return getSalesforceD2CStores( domain, clientId, clientSecret ); + }, [ domain, clientId, clientSecret ] ); + + const { + data: stores, + isLoading: fetchingStores, + error: storesError, + refetch: fetchStores, + } = useQuery( queryFn, { manualFetchOnly: true } ); + + const debouncedFetchStores = useDebounce( fetchStores, 500 ); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect( debouncedFetchStores, [ domain, clientId, clientSecret ] ); + + return { stores, fetchingStores, fetchStores, storesError }; +}; diff --git a/src/data-sources/http/HttpSettings.tsx b/src/data-sources/http/HttpSettings.tsx index 82e8a17a..ab4f50fd 100644 --- a/src/data-sources/http/HttpSettings.tsx +++ b/src/data-sources/http/HttpSettings.tsx @@ -3,6 +3,7 @@ import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; import { HttpAuthSettingsInput } from '@/data-sources/components/HttpAuthSettingsInput'; +import { ConfigSource } from '@/data-sources/constants'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { HttpAuth } from '@/data-sources/http/types'; import { HttpConfig, HttpServiceConfig, SettingsComponentProps } from '@/data-sources/types'; @@ -63,6 +64,7 @@ export const HttpSettings = ( { mode, uuid, config }: SettingsComponentProps< Ht service: 'generic-http', service_config: validState, uuid: uuid ?? null, + config_source: ConfigSource.STORAGE, }; return onSave( httpConfig, mode ); diff --git a/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx b/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx deleted file mode 100644 index 9ca6a8bf..00000000 --- a/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { TextControl } from '@wordpress/components'; -import { useMemo } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -import { DataSourceForm } from '../components/DataSourceForm'; -import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; -import { useDataSources } from '@/data-sources/hooks/useDataSources'; -import { - SettingsComponentProps, - SalesforceB2CConfig, - SalesforceB2CServiceConfig, -} from '@/data-sources/types'; -import { useForm } from '@/hooks/useForm'; -import SalesforceCommerceB2CIcon from '@/settings/icons/SalesforceCommerceB2CIcon'; - -const SERVICE_CONFIG_VERSION = 1; - -export const SalesforceB2CSettings = ( { - mode, - uuid, - config, -}: SettingsComponentProps< SalesforceB2CConfig > ) => { - const { onSave } = useDataSources< SalesforceB2CConfig >( false ); - - const { state, handleOnChange, validState } = useForm< SalesforceB2CServiceConfig >( { - initialValues: config?.service_config ?? { - __version: SERVICE_CONFIG_VERSION, - enable_blocks: true, - }, - } ); - - const shouldAllowSubmit = useMemo( () => { - return state.shortcode && state.organization_id && state.client_id && state.client_secret; - }, [ state.shortcode, state.organization_id, state.client_id, state.client_secret ] ); - - const onSaveClick = async () => { - if ( ! validState ) { - return; - } - - const data: SalesforceB2CConfig = { - service: 'salesforce-b2c', - service_config: validState, - uuid: uuid ?? null, - }; - - return onSave( data, mode ); - }; - - return ( - - - { - handleOnChange( 'shortcode', shortCode ?? '' ); - } } - value={ state.shortcode ?? '' } - help={ __( 'The region-specific merchant identifier. Example: 0dnz6ope' ) } - autoComplete="off" - __next40pxDefaultSize - /> - - { - handleOnChange( 'organization_id', shortCode ?? '' ); - } } - value={ state.organization_id ?? '' } - help={ __( 'The organization ID. Example: f_ecom_mirl_012' ) } - autoComplete="off" - __next40pxDefaultSize - /> - - { - handleOnChange( 'client_id', shortCode ?? '' ); - } } - value={ state.client_id ?? '' } - help={ __( 'Example: bc2991f1-eec8-4976-8774-935cbbe84f18' ) } - autoComplete="off" - __next40pxDefaultSize - /> - - { - handleOnChange( 'client_secret', shortCode ?? '' ); - } } - value={ state.client_secret } - /> - - - - ); -}; diff --git a/src/data-sources/salesforce-d2c/SalesforceD2CSettings.tsx b/src/data-sources/salesforce-d2c/SalesforceD2CSettings.tsx new file mode 100644 index 00000000..76671f6f --- /dev/null +++ b/src/data-sources/salesforce-d2c/SalesforceD2CSettings.tsx @@ -0,0 +1,254 @@ +import { SelectControl, TextControl } from '@wordpress/components'; +import { useEffect, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +import { DataSourceForm } from '@/data-sources/components/DataSourceForm'; +import { ConfigSource } from '@/data-sources/constants'; +import { useDataSources } from '@/data-sources/hooks/useDataSources'; +import { useSalesforceD2CAuth } from '@/data-sources/hooks/useSalesforceD2CAuth'; +import { + SalesforceD2CConfig, + SalesforceD2CServiceConfig, + SettingsComponentProps, +} from '@/data-sources/types'; +import { getConnectionMessage } from '@/data-sources/utils'; +import { useForm, ValidationRules } from '@/hooks/useForm'; +import SalesforceCommerceD2CIcon from '@/settings/icons/SalesforceCommerceD2CIcon'; +import { SelectOption } from '@/types/input'; + +const SERVICE_CONFIG_VERSION = 1; + +const defaultSelectOption: SelectOption = { + disabled: true, + label: __( 'Select an option', 'remote-data-blocks' ), + value: '', +}; + +const validationRules: ValidationRules< SalesforceD2CServiceConfig > = { + client_id: ( state: Partial< SalesforceD2CServiceConfig > ) => { + if ( ! state.client_id ) { + return __( 'Please provide a valid client ID.', 'remote-data-blocks' ); + } + + return null; + }, + + client_secret: ( state: Partial< SalesforceD2CServiceConfig > ) => { + if ( ! state.client_secret ) { + return __( 'Please provide a valid client secret.', 'remote-data-blocks' ); + } + + return null; + }, + + domain: ( state: Partial< SalesforceD2CServiceConfig > ) => { + if ( ! state.domain ) { + return __( + 'Please provide a valid domain. Example: https://scomhello123usa456org.lightning.force.com will have a valid domain of scomhello123usa456org.', + 'remote-data-blocks' + ); + } + + return null; + }, +}; + +export const SalesforceD2CSettings = ( { + mode, + uuid, + config, +}: SettingsComponentProps< SalesforceD2CConfig > ) => { + const { onSave } = useDataSources< SalesforceD2CConfig >( false ); + + const { state, handleOnChange, validState } = useForm< SalesforceD2CServiceConfig >( { + initialValues: config?.service_config ?? { + __version: SERVICE_CONFIG_VERSION, + enable_blocks: true, + }, + validationRules, + } ); + + const [ storeOptions, setStoreOptions ] = useState< SelectOption[] >( [ + { + ...defaultSelectOption, + label: __( 'Auto-filled on successful connection.', 'remote-data-blocks' ), + }, + ] ); + + const { stores, fetchingStores, storesError } = useSalesforceD2CAuth( + state.domain ?? '', + state.client_id ?? '', + state.client_secret ?? '' + ); + + const onSaveClick = async () => { + if ( ! validState ) { + return; + } + + const data: SalesforceD2CConfig = { + service: 'salesforce-d2c', + service_config: validState, + uuid: uuid ?? null, + config_source: ConfigSource.STORAGE, + }; + + return onSave( data, mode ); + }; + + const onDomainChange = ( value: string ) => { + if ( ! value ) { + handleOnChange( 'domain', '' ); + handleOnChange( 'store_id', '' ); + return; + } + + const lighteningUrlPattern = /^https?:\/\/([^.]+)\.lightning\.force\.com$/; + + const lighteningMatch = value.match( lighteningUrlPattern ); + + if ( lighteningMatch ) { + handleOnChange( 'domain', lighteningMatch[ 1 ] ); + handleOnChange( 'store_id', '' ); + return; + } + + const salesforceUrlPattern = /^https?:\/\/([^.]+)\.my\.salesforce\.com$/; + const salesforceMatch = value.match( salesforceUrlPattern ); + + if ( salesforceMatch ) { + handleOnChange( 'domain', salesforceMatch[ 1 ] ); + handleOnChange( 'store_id', '' ); + return; + } + + handleOnChange( 'domain', value ); + handleOnChange( 'store_id', '' ); + }; + + const onClientIDChange = ( value: string ) => { + handleOnChange( 'client_id', value ); + handleOnChange( 'store_id', '' ); + }; + + const onClientSecretChange = ( value: string ) => { + handleOnChange( 'client_secret', value ); + handleOnChange( 'store_id', '' ); + }; + + const onStoreIDChange = ( value: string ) => { + const selectedStore = stores?.find( store => store.id === value ); + handleOnChange( 'store_id', selectedStore?.id ?? '' ); + }; + + const credentialsHelpText = useMemo( () => { + if ( fetchingStores ) { + return __( 'Checking credentials...', 'remote-data-blocks' ); + } else if ( storesError ) { + const errorMessage = storesError.message ?? __( 'Unknown error', 'remote-data-blocks' ); + return getConnectionMessage( + 'error', + __( 'Failed to generate token using provided credentials: ', 'remote-data-blocks' ) + + ' ' + + errorMessage + ); + } else if ( stores ) { + return getConnectionMessage( + 'success', + __( 'Credentials are valid. Stores fetched successfully.', 'remote-data-blocks' ) + ); + } + return __( 'The client secret for your Salesforce D2C instance.', 'remote-data-blocks' ); + }, [ fetchingStores, stores, storesError ] ); + + const shouldAllowSubmit = state.store_id && state.store_id !== ''; + + useEffect( () => { + if ( ! stores?.length ) { + return; + } + + setStoreOptions( [ + { + ...defaultSelectOption, + label: __( 'Select a store', 'remote-data-blocks' ), + }, + ...( stores ?? [] ).map( ( { name, id } ) => ( { + label: name, + value: id, + } ) ), + ] ); + }, [ stores ] ); + + return ( + + 0 ) } + displayName={ state.display_name ?? '' } + handleOnChange={ handleOnChange } + heading={ { + icon: SalesforceCommerceD2CIcon, + width: '100px', + height: '75px', + verticalAlign: 'text-top', + } } + inputIcon={ SalesforceCommerceD2CIcon } + uuid={ uuid } + > + + { __( 'Example: https://' ) } + { __( 'your-domain' ) } + { __( '.lightning.force.com' ) } + { __( ' or ' ) } + { __( 'https://' ) } + { __( 'your-domain' ) } + { __( '.my.salesforce.com' ) } + + } + autoComplete="off" + __nextHasNoMarginBottom + /> + + + + + + + + + + + ); +}; diff --git a/src/data-sources/shopify/ShopifySettings.tsx b/src/data-sources/shopify/ShopifySettings.tsx index f8129269..e05d8329 100644 --- a/src/data-sources/shopify/ShopifySettings.tsx +++ b/src/data-sources/shopify/ShopifySettings.tsx @@ -4,6 +4,7 @@ import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; +import { ConfigSource } from '@/data-sources/constants'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { useShopifyShopName } from '@/data-sources/hooks/useShopify'; import { SettingsComponentProps, ShopifyConfig, ShopifyServiceConfig } from '@/data-sources/types'; @@ -59,6 +60,7 @@ export const ShopifySettings = ( { service: 'shopify', service_config: validState, uuid: uuid ?? null, + config_source: ConfigSource.STORAGE, }; return onSave( data, mode ); @@ -110,7 +112,7 @@ export const ShopifySettings = ( { ); diff --git a/src/data-sources/types.ts b/src/data-sources/types.ts index 7790e6ec..1f6d97b1 100644 --- a/src/data-sources/types.ts +++ b/src/data-sources/types.ts @@ -1,4 +1,4 @@ -import { SUPPORTED_SERVICES } from '@/data-sources/constants'; +import { SUPPORTED_SERVICES, ConfigSource } from '@/data-sources/constants'; import { HttpAuth } from '@/data-sources/http/types'; import { StringIdName } from '@/types/common'; import { GoogleServiceAccountKey } from '@/types/google'; @@ -10,7 +10,6 @@ interface BaseServiceConfig extends Record< string, unknown > { display_name: string; enable_blocks: boolean; } - interface BaseDataSourceConfig< ServiceName extends DataSourceType, ServiceConfig extends BaseServiceConfig @@ -18,6 +17,7 @@ interface BaseDataSourceConfig< service: ServiceName; service_config: ServiceConfig; uuid: string | null; + config_source: ConfigSource; } export interface DataSourceQueryMappingValue { @@ -53,11 +53,26 @@ export interface HttpServiceConfig extends BaseServiceConfig { endpoint: string; } -export interface SalesforceB2CServiceConfig extends BaseServiceConfig { - shortcode: string; - organization_id: string; +export interface SalesforceD2CStoreConfig extends StringIdName { + output_query_mappings: DataSourceQueryMappingValue[]; +} + +export interface SalesforceD2CServiceConfig extends BaseServiceConfig { client_id: string; client_secret: string; + store_id: string; + domain: string; +} + +export interface SalesforceD2CWebStoreRecord { + /** The name of the WebStore */ + name: string; + /** The unique identifier for the WebStore */ + id: string; +} + +export interface SalesforceD2CWebStoresResponse { + webstores: SalesforceD2CWebStoreRecord[]; } export interface ShopifyServiceConfig extends BaseServiceConfig { @@ -68,9 +83,9 @@ export interface ShopifyServiceConfig extends BaseServiceConfig { export type AirtableConfig = BaseDataSourceConfig< 'airtable', AirtableServiceConfig >; export type GoogleSheetsConfig = BaseDataSourceConfig< 'google-sheets', GoogleSheetsServiceConfig >; export type HttpConfig = BaseDataSourceConfig< 'generic-http', HttpServiceConfig >; -export type SalesforceB2CConfig = BaseDataSourceConfig< - 'salesforce-b2c', - SalesforceB2CServiceConfig +export type SalesforceD2CConfig = BaseDataSourceConfig< + 'salesforce-d2c', + SalesforceD2CServiceConfig >; export type ShopifyConfig = BaseDataSourceConfig< 'shopify', ShopifyServiceConfig >; @@ -78,7 +93,7 @@ export type DataSourceConfig = | AirtableConfig | GoogleSheetsConfig | HttpConfig - | SalesforceB2CConfig + | SalesforceD2CConfig | ShopifyConfig; export type SettingsComponentProps< T extends DataSourceConfig > = { diff --git a/src/pattern-editor/components/PatternEditorSettingsPanel.tsx b/src/pattern-editor/components/PatternEditorSettingsPanel.tsx index 9fc5b75a..41933472 100644 --- a/src/pattern-editor/components/PatternEditorSettingsPanel.tsx +++ b/src/pattern-editor/components/PatternEditorSettingsPanel.tsx @@ -25,7 +25,7 @@ export function PatternEditorSettingsPanel() { function updateBlockTypes( blockName: string ): void { updatePostMeta( { ...postMeta, [ PATTERN_BLOCK_TYPE_POST_META_KEY ]: blockName } ); - sendTracksEvent( 'remotedatablocks_associate_block_type_to_pattern', { + sendTracksEvent( 'associate_block_type_to_pattern', { data_source_type: getBlockDataSourceType( blockName ), is_pattern_synced: isSynced, } ); diff --git a/src/settings/icons/SalesforceCommerceB2CIcon.tsx b/src/settings/icons/SalesforceCommerceD2CIcon.tsx similarity index 99% rename from src/settings/icons/SalesforceCommerceB2CIcon.tsx rename to src/settings/icons/SalesforceCommerceD2CIcon.tsx index 7d33e4df..e8a4dbff 100644 --- a/src/settings/icons/SalesforceCommerceB2CIcon.tsx +++ b/src/settings/icons/SalesforceCommerceD2CIcon.tsx @@ -1,6 +1,6 @@ import { SVG } from '@wordpress/primitives'; -const SalesforceCommerceB2CIcon = ( +const SalesforceCommerceD2CIcon = ( ); -export default SalesforceCommerceB2CIcon; +export default SalesforceCommerceD2CIcon; diff --git a/src/settings/index.scss b/src/settings/index.scss index f39d656c..ef2cac0a 100644 --- a/src/settings/index.scss +++ b/src/settings/index.scss @@ -153,16 +153,11 @@ body { padding: 8px; } - .components-button.has-icon.has-text.rdb-settings-page_add-data-source-btn-salesforce-b2c { + .components-button.has-icon.has-text.rdb-settings-page_add-data-source-btn-salesforce-d2c { svg { padding: 0; } - - span.components-menu-item__item { - text-wrap: auto; - min-width: 112px; - } } } } diff --git a/src/utils/input-validation.ts b/src/utils/input-validation.ts new file mode 100644 index 00000000..4df23b11 --- /dev/null +++ b/src/utils/input-validation.ts @@ -0,0 +1,60 @@ +export enum QueryValidationErrorType { + MissingRequiredInput = 'missing_required_input', +} + +export class QueryInputValidationError extends Error { + constructor( + message: string, + public type: QueryValidationErrorType, + public affectedInputVariables: InputVariable[] + ) { + super( message ); + } +} + +/** + * Validate remote data query input. + * + * TODO: Additional type validation beyond required fields. + * + * @throws {Error} If query input is invalid or missing required variables. + */ +export function validateQueryInput( + queryInput: RemoteDataQueryInput, + inputVariables: InputVariable[] +): boolean { + const requiredInputVariables = inputVariables.filter( input => input.required ); + + // Ensure query input is not missing required variables. We define "missing" + // as any nullish value. An empty string or boolean `false`, for example, are + // not nullish. + const missingRequiredInputVariables = requiredInputVariables.filter( + input => null === ( queryInput[ input.slug ] ?? null ) + ); + + if ( missingRequiredInputVariables.length ) { + throw new QueryInputValidationError( + 'Missing required query input variables', + QueryValidationErrorType.MissingRequiredInput, + missingRequiredInputVariables + ); + } + + return true; +} + +/** + * Wrapper around `validateQueryInput` that returns a boolean instead of throwing + * an error. + */ +export function isQueryInputValid( + queryInput: RemoteDataQueryInput, + inputVariables: InputVariable[] +): boolean { + try { + validateQueryInput( queryInput, inputVariables ); + return true; + } catch ( error ) { + return false; + } +} diff --git a/tests/inc/PluginSettings/PluginSettingsTest.php b/tests/inc/PluginSettings/PluginSettingsTest.php new file mode 100644 index 00000000..096b882c --- /dev/null +++ b/tests/inc/PluginSettings/PluginSettingsTest.php @@ -0,0 +1,79 @@ + '1', + 'service' => 'generic-http', + 'config_source' => 'code', + 'service_config' => [ + 'display_name' => 'Test HTTP Source', + 'endpoint' => 'https://api.example.com', + ], + ], + [ + 'uuid' => '2', + 'service' => 'airtable', + 'config_source' => 'storage', + 'service_config' => [ + 'display_name' => 'Test Airtable Source', + ], + ], + ]; + + // Mock WordPress functions + MockWordPressFunctions::add_mock_filter( 'esc_html__', 'Loading…' ); + + // Mock static methods using Mockery + $config_manager_mock = Mockery::mock( 'alias:' . DataSourceConfigManager::class ); + $config_manager_mock->shouldReceive( 'get_all' ) + ->once() + ->andReturn( $mock_configs ); + + $telemetry_mock = Mockery::mock( 'alias:' . DataSourceTelemetry::class ); + $telemetry_mock->shouldReceive( 'track_view' ) + ->once() + ->with( Mockery::on( function ( $configs ) use ( $mock_configs ) { + $this->assertEquals( $mock_configs, $configs ); + return true; + } ) ); + + // Start output buffering to capture printf output + ob_start(); + + // Call the method we're testing + PluginSettings::settings_page_content(); + + // Get and clean the output buffer + $output = ob_get_clean(); + + // Verify the output + $this->assertEquals( + '
+
Loading…
+
', + $output + ); + } +} diff --git a/tests/inc/Store/DataSource/ConstantConfigStoreTest.php b/tests/inc/Store/DataSource/ConstantConfigStoreTest.php new file mode 100644 index 00000000..0d1d473d --- /dev/null +++ b/tests/inc/Store/DataSource/ConstantConfigStoreTest.php @@ -0,0 +1,109 @@ +valid_config = [ + 'uuid' => self::TEST_UUID, + 'service' => self::TEST_SERVICE, + 'service_config' => [ + '__version' => 1, + 'store_name' => 'test-store', + 'access_token' => 'gy56yrtyrtt', + 'display_name' => 'Test Store', + ], + ]; + + $this->invalid_config = [ + 'uuid' => self::TEST_UUID_2, + 'service' => self::INVALID_SERVICE, + 'service_config' => [], + ]; + + $mock_data_source = Mockery::mock( DataSourceInterface::class ); + $mock_class = Mockery::mock( 'overload:' . HttpDataSource::class ); + $mock_class->shouldReceive( 'from_array' )->andReturn( $mock_data_source ); + + if ( ! defined( 'REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP' ) ) { + define( 'REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP', [ + self::TEST_SERVICE => HttpDataSource::class, + ] ); + } + } + + protected function tearDown(): void { + Mockery::close(); + parent::tearDown(); + } + + private function defineConfigs( mixed $configs ): void { + if ( ! defined( self::CONFIG_CONSTANT ) ) { + define( self::CONFIG_CONSTANT, $configs ); + } + } + + public function testGetConfigsReturnsEmptyArrayWhenConstantNotDefined(): void { + $this->assertSame( [], ConstantConfigStore::get_configs() ); + } + + public function testGetConfigsReturnsOnlyValidConfigs(): void { + $this->defineConfigs( [ $this->valid_config, $this->invalid_config ] ); + + $configs = ConstantConfigStore::get_configs(); + $this->assertCount( 1, $configs ); + $this->assertSame( $this->valid_config, $configs[0] ); + } + + public function testGetConfigByUuidReturnsErrorWhenConfigNotFound(): void { + $this->defineConfigs( [] ); + + $result = ConstantConfigStore::get_config_by_uuid( self::TEST_UUID ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'data_source_not_found', $result->get_error_code() ); + } + + public function testGetConfigByUuidReturnsErrorForInvalidConfig(): void { + $invalid_config = [ + 'uuid' => self::TEST_UUID, + 'service' => self::INVALID_SERVICE, + 'service_config' => [], + ]; + + $this->defineConfigs( [ $invalid_config ] ); + + $result = ConstantConfigStore::get_config_by_uuid( self::TEST_UUID ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'unsupported_data_source', $result->get_error_code() ); + } + + public function testGetConfigByUuidReturnsConfigWhenValid(): void { + $this->defineConfigs( [ $this->valid_config ] ); + + $result = ConstantConfigStore::get_config_by_uuid( self::TEST_UUID ); + $this->assertIsArray( $result ); + $this->assertSame( $this->valid_config, $result ); + } +} diff --git a/tests/inc/Store/DataSource/DataSourceConfigManagerTest.php b/tests/inc/Store/DataSource/DataSourceConfigManagerTest.php new file mode 100644 index 00000000..06e717df --- /dev/null +++ b/tests/inc/Store/DataSource/DataSourceConfigManagerTest.php @@ -0,0 +1,543 @@ +airtable_storage_config = [ + 'uuid' => self::AIRTABLE_UUID, + 'service' => self::AIRTABLE_SERVICE, + 'service_config' => [ + '__version' => 1, + 'enable_blocks' => true, + 'display_name' => 'Test Airtable', + 'access_token' => 'test.airtable.access-token', + 'tables' => [ + [ + 'id' => 'test_table_id', + 'name' => 'Test Table', + 'output_query_mappings' => [ + [ + 'path' => '$.fields["Name"]', + 'name' => 'Name', + 'key' => 'Name', + 'type' => 'string', + ], + ], + ], + ], + 'base' => [ + 'id' => 'test_base_id', + 'name' => 'Test Base', + ], + ], + '__metadata' => [ + 'created_at' => '2025-02-14 11:05:36', + 'updated_at' => '2025-02-14 11:05:49', + ], + 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_STORAGE, + ]; + + $this->sheets_constant_config = [ + 'uuid' => self::SHEETS_UUID, + 'service' => self::SHEETS_SERVICE, + 'service_config' => [ + '__version' => 1, + 'enable_blocks' => true, + 'display_name' => 'Test Google Sheets', + 'credentials' => [ + 'type' => 'service_account', + 'project_id' => 'test-gcp-project', + 'private_key_id' => 'xyz987abc654def321ghi', + 'private_key' => '-----BEGIN PRIVATE KEY-----\nREDACTED\n-----END PRIVATE KEY-----\n', + 'client_email' => 'test-gcp-project@test-gcp-project.iam.gserviceaccount.com', + 'client_id' => '1234567890', + 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth', + 'token_uri' => 'https://oauth2.googleapis.com/token', + 'auth_provider_x509_cert_url' => 'https://www.googleapis.com/oauth2/v1/certs', + 'client_x509_cert_url' => 'https://www.googleapis.com/robot/v1/metadata/x509/test%40test-gcp-project.iam.gserviceaccount.com', + 'universe_domain' => 'googleapis.com', + ], + 'sheets' => [ + [ + 'id' => '0', + 'name' => 'Test Sheet', + 'output_query_mappings' => [ + [ + 'key' => 'Name', + 'name' => 'Name', + 'path' => '$["Name"]', + 'type' => 'string', + ], + ], + ], + ], + 'spreadsheet' => [ + 'id' => 'some-spreadsheet-id', + 'name' => 'Test Spreadsheet', + ], + ], + 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_CONSTANT, + ]; + + $this->shopify_code_config = [ + 'uuid' => self::SHOPIFY_UUID, + 'service' => self::SHOPIFY_SERVICE, + 'service_config' => [ + '__version' => 1, + 'access_token' => 'shpat_abc123def456ghi789jkl0', + 'store_name' => 'test-shopify-store', + 'display_name' => 'Test Shopify Store', + 'enable_blocks' => true, + ], + 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_CODE, + ]; + } + + protected function tearDown(): void { + Mockery::close(); + parent::tearDown(); + } + + public function testGetAllReturnsConfigsFromAllSources(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->sheets_constant_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $this->shopify_code_config ] ); + + $result = DataSourceConfigManager::get_all(); + + $this->assertCount( 3, $result ); + $this->assertContains( $this->airtable_storage_config, $result ); + $this->assertContains( $this->sheets_constant_config, $result ); + $this->assertContains( $this->shopify_code_config, $result ); + } + + public function testGetReturnsConfigFromConstant(): void { + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_config_by_uuid' ) + ->with( self::SHEETS_UUID ) + ->andReturn( $this->sheets_constant_config ); + + $result = DataSourceConfigManager::get( self::SHEETS_UUID ); + $this->assertSame( $this->sheets_constant_config, $result ); + } + + public function testGetReturnsConfigFromStorage(): void { + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_config_by_uuid' ) + ->with( self::AIRTABLE_UUID ) + ->andReturn( new WP_Error( 'not_found' ) ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_config_by_uuid' ) + ->with( self::AIRTABLE_UUID ) + ->andReturn( $this->airtable_storage_config ); + + $result = DataSourceConfigManager::get( self::AIRTABLE_UUID ); + $this->assertSame( $this->airtable_storage_config, $result ); + } + + public function testGetReturnsErrorWhenConfigNotFound(): void { + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_config_by_uuid' ) + ->with( self::AIRTABLE_UUID ) + ->andReturn( new WP_Error( 'not_found' ) ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_config_by_uuid' ) + ->with( self::AIRTABLE_UUID ) + ->andReturn( new WP_Error( 'not_found' ) ); + + $result = DataSourceConfigManager::get( self::AIRTABLE_UUID ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'data_source_not_found', $result->get_error_code() ); + } + + public function testCreateReturnsNewConfig(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'create_config' ) + ->with( $this->airtable_storage_config ) + ->andReturn( $this->airtable_storage_config ); + + $result = DataSourceConfigManager::create( $this->airtable_storage_config ); + $this->assertSame( $this->airtable_storage_config, $result ); + } + + public function testCreateReturnsErrorOnFailure(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'create_config' ) + ->with( $this->airtable_storage_config ) + ->andReturn( new WP_Error( 'create_failed' ) ); + + $result = DataSourceConfigManager::create( $this->airtable_storage_config ); + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function testUpdateReturnsUpdatedConfig(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'update_config_by_uuid' ) + ->with( self::AIRTABLE_UUID, $this->airtable_storage_config ) + ->andReturn( $this->airtable_storage_config ); + + $result = DataSourceConfigManager::update( self::AIRTABLE_UUID, $this->airtable_storage_config ); + $this->assertSame( $this->airtable_storage_config, $result ); + } + + public function testUpdateReturnsErrorForImmutableConfig(): void { + $immutable_config = array_merge( + $this->sheets_constant_config, + [ 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_CONSTANT ] + ); + + $result = DataSourceConfigManager::update( self::SHEETS_UUID, $immutable_config ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'cannot_update_config', $result->get_error_code() ); + } + + public function testUpdateReturnsErrorOnFailure(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'update_config_by_uuid' ) + ->with( self::AIRTABLE_UUID, $this->airtable_storage_config ) + ->andReturn( new WP_Error( 'update_failed' ) ); + + $result = DataSourceConfigManager::update( self::AIRTABLE_UUID, $this->airtable_storage_config ); + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function testDeleteReturnsTrue(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'delete_config_by_uuid' ) + ->with( self::AIRTABLE_UUID ) + ->andReturn( true ); + + $result = DataSourceConfigManager::delete( self::AIRTABLE_UUID ); + $this->assertTrue( $result ); + } + + public function testDeleteReturnsErrorOnFailure(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'delete_config_by_uuid' ) + ->with( self::AIRTABLE_UUID ) + ->andReturn( new WP_Error( 'delete_failed' ) ); + + $result = DataSourceConfigManager::delete( self::AIRTABLE_UUID ); + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function testGetAllHandlesConfigPrecedenceCorrectly(): void { + // Create configs with same UUID to test precedence + $storage_sheets = array_merge( $this->sheets_constant_config, [ + 'service_config' => array_merge( $this->sheets_constant_config['service_config'], [ + 'display_name' => 'Storage Sheets', + ] ), + 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_STORAGE, + ] ); + + $constant_sheets = array_merge( $this->sheets_constant_config, [ + 'service_config' => array_merge( $this->sheets_constant_config['service_config'], [ + 'display_name' => 'Constant Sheets', + ] ), + 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_CONSTANT, + ] ); + + $code_sheets = array_merge( $this->sheets_constant_config, [ + 'service_config' => array_merge( $this->sheets_constant_config['service_config'], [ + 'display_name' => 'Code Sheets', + ] ), + 'config_source' => DataSourceConfigManager::CONFIG_SOURCE_CODE, + ] ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $storage_sheets ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $constant_sheets ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $code_sheets ] ); + + $result = DataSourceConfigManager::get_all(); + + // Should only get one config since they share the same UUID + $this->assertCount( 1, $result ); + + // Storage should win due to highest precedence + $this->assertContains( $storage_sheets, $result ); + $this->assertNotContains( $constant_sheets, $result ); + $this->assertNotContains( $code_sheets, $result ); + } + + public function testGetAllWithServiceFilter(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->sheets_constant_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $this->shopify_code_config ] ); + + $result = DataSourceConfigManager::get_all( [ 'service' => self::AIRTABLE_SERVICE ] ); + + $this->assertCount( 1, $result ); + $this->assertContains( $this->airtable_storage_config, $result ); + } + + public function testGetAllWithEnableBlocksFilterTrue(): void { + // Modify one config to have enable_blocks false + $sheets_config_blocks_disabled = array_merge_recursive( $this->sheets_constant_config, [ + 'service_config' => [ 'enable_blocks' => false ], + ] ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $sheets_config_blocks_disabled ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $this->shopify_code_config ] ); + + $result = DataSourceConfigManager::get_all( [ 'enable_blocks' => true ] ); + + $this->assertCount( 2, $result ); + $this->assertContains( $this->airtable_storage_config, $result ); + $this->assertContains( $this->shopify_code_config, $result ); + $this->assertNotContains( $sheets_config_blocks_disabled, $result ); + } + + public function testGetAllWithEnableBlocksFilterFalse(): void { + // Create config with enable_blocks not set + $shopify_config_blocks_unset = $this->shopify_code_config; + unset( $shopify_config_blocks_unset['service_config']['enable_blocks'] ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->sheets_constant_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $shopify_config_blocks_unset ] ); + + $result = DataSourceConfigManager::get_all( [ 'enable_blocks' => false ] ); + + $this->assertCount( 1, $result ); + $this->assertContains( $shopify_config_blocks_unset, $result ); + } + + public function testGetAllWithMultipleFilters(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->sheets_constant_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $this->shopify_code_config ] ); + + $result = DataSourceConfigManager::get_all( [ + 'service' => self::AIRTABLE_SERVICE, + 'enable_blocks' => true, + ] ); + + $this->assertCount( 1, $result ); + $this->assertContains( $this->airtable_storage_config, $result ); + } + + public function testGetAllReturnsEmptyWhenServiceDoesNotMatch(): void { + // Only return configs for sheets and shopify, but search for airtable + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->sheets_constant_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->shopify_code_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [] ); + + $result = DataSourceConfigManager::get_all( [ 'service' => self::AIRTABLE_SERVICE ] ); + + $this->assertCount( 0, $result ); + $this->assertEmpty( $result ); + } + + public function testGetAllReturnsEmptyWhenEnableBlocksDoesNotMatch(): void { + // Set all configs to have enable_blocks = true + $airtable_config = array_merge_recursive( $this->airtable_storage_config, [ + 'service_config' => [ 'enable_blocks' => true ], + ] ); + $sheets_config = array_merge_recursive( $this->sheets_constant_config, [ + 'service_config' => [ 'enable_blocks' => true ], + ] ); + $shopify_config = array_merge_recursive( $this->shopify_code_config, [ + 'service_config' => [ 'enable_blocks' => true ], + ] ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $airtable_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $sheets_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $shopify_config ] ); + + $result = DataSourceConfigManager::get_all( [ 'enable_blocks' => false ] ); + + $this->assertCount( 0, $result ); + $this->assertEmpty( $result ); + } + + public function testGetAllReturnsEmptyWhenMultipleFiltersDoNotMatch(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->sheets_constant_config ] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [ $this->shopify_code_config ] ); + + $result = DataSourceConfigManager::get_all( [ + 'service' => self::AIRTABLE_SERVICE, + 'enable_blocks' => false, + ] ); + + $this->assertCount( 0, $result ); + $this->assertEmpty( $result ); + } + + public function testGetAllThrowsErrorForUnsupportedFilters(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [] ); + + $result = DataSourceConfigManager::get_all( [ + 'display_name' => 'Test Airtable', + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_filter', $result->get_error_code() ); + $this->assertSame( 'Invalid filter key: display_name', $result->get_error_message() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + + public function testGetAllWithMultipleFiltersIncludingInvalid(): void { + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $this->airtable_storage_config ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [] ); + + $result = DataSourceConfigManager::get_all( [ + 'service' => self::AIRTABLE_SERVICE, + 'display_name' => 'Test Airtable', + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_filter', $result->get_error_code() ); + $this->assertSame( 'Invalid filter key: display_name', $result->get_error_message() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + + public function testGetAllHandlesNullEnableBlocksValue(): void { + // Create config with enable_blocks explicitly set to null + $config_with_null_blocks = array_replace_recursive( $this->airtable_storage_config, [ + 'service_config' => [ 'enable_blocks' => null ], + ] ); + + $mock_storage_crud = Mockery::mock( 'alias:' . DataSourceCrud::class ); + $mock_storage_crud->shouldReceive( 'get_configs' ) + ->andReturn( [ $config_with_null_blocks ] ); + + $mock_constant = Mockery::mock( 'alias:' . ConstantConfigStore::class ); + $mock_constant->shouldReceive( 'get_configs' ) + ->andReturn( [] ); + + $mock_config_store = Mockery::mock( 'alias:' . ConfigStore::class ); + $mock_config_store->shouldReceive( 'get_data_sources_as_array' ) + ->andReturn( [] ); + + // Should match when filtering for enable_blocks = false + $result_false = DataSourceConfigManager::get_all( [ 'enable_blocks' => false ] ); + $this->assertCount( 1, $result_false ); + $this->assertContains( $config_with_null_blocks, $result_false ); + + // Should not match when filtering for enable_blocks = true + $result_true = DataSourceConfigManager::get_all( [ 'enable_blocks' => true ] ); + $this->assertCount( 0, $result_true ); + $this->assertEmpty( $result_true ); + } +} diff --git a/tests/inc/Analytics/EnvironmentConfigTest.php b/tests/inc/Telemetry/EnvironmentConfigTest.php similarity index 95% rename from tests/inc/Analytics/EnvironmentConfigTest.php rename to tests/inc/Telemetry/EnvironmentConfigTest.php index 7c017485..0810f08d 100644 --- a/tests/inc/Analytics/EnvironmentConfigTest.php +++ b/tests/inc/Telemetry/EnvironmentConfigTest.php @@ -1,10 +1,10 @@ assertEquals( null, TracksAnalytics::get_instance() ); + $this->assertEquals( null, TracksTelemetry::get_instance() ); } public function testInitDoesNotSetTracksOnLocalEnvironment(): void { @@ -31,9 +31,9 @@ public function testInitDoesNotSetTracksOnLocalEnvironment(): void { $env_config_mock->method( 'is_local_env' )->with()->willReturn( true ); $env_config_mock->method( 'is_enabled_via_filter' )->with()->willReturn( true ); - TracksAnalytics::init( $env_config_mock ); + TracksTelemetry::init( $env_config_mock ); - $this->assertEquals( null, TracksAnalytics::get_instance() ); + $this->assertEquals( null, TracksTelemetry::get_instance() ); } public function testInitDoesSetTracksIfTrackingIsEnabledViaFilter(): void { @@ -43,9 +43,9 @@ public function testInitDoesSetTracksIfTrackingIsEnabledViaFilter(): void { $env_config_mock->method( 'get_tracks_lib_class' )->with()->willReturn( MockTracks::class ); $env_config_mock->expects( $this->once() )->method( 'get_remote_data_blocks_properties' )->with(); - TracksAnalytics::init( $env_config_mock ); + TracksTelemetry::init( $env_config_mock ); - $this->assertInstanceOf( MockTracks::class, TracksAnalytics::get_instance() ); + $this->assertInstanceOf( MockTracks::class, TracksTelemetry::get_instance() ); } public function testInitDoesSetTracksIfTrackingIsEnabledOnVipSite(): void { @@ -55,9 +55,9 @@ public function testInitDoesSetTracksIfTrackingIsEnabledOnVipSite(): void { $env_config_mock->method( 'get_tracks_lib_class' )->with()->willReturn( MockTracks::class ); $env_config_mock->expects( $this->once() )->method( 'get_remote_data_blocks_properties' )->with(); - TracksAnalytics::init( $env_config_mock ); + TracksTelemetry::init( $env_config_mock ); - $this->assertInstanceOf( MockTracks::class, TracksAnalytics::get_instance() ); + $this->assertInstanceOf( MockTracks::class, TracksTelemetry::get_instance() ); } public function testGetGlobalProperties(): void { @@ -65,12 +65,12 @@ public function testGetGlobalProperties(): void { $env_config_mock = $this->getMockBuilder( EnvironmentConfig::class )->onlyMethods( [ 'get_tracks_core_properties' ] )->getMock(); $env_config_mock->expects( $this->exactly( 2 ) )->method( 'get_tracks_core_properties' )->with()->willReturn( [ 'vip_env' => '123' ] ); - TracksAnalytics::init( $env_config_mock ); + TracksTelemetry::init( $env_config_mock ); $this->assertEquals( [ 'plugin_version' => '', 'vip_env' => '123', - ], TracksAnalytics::get_global_properties() ); + ], TracksTelemetry::get_global_properties() ); } public function testTrackPluginActivationDoesNotRecordEventIfPluginIsNotRDB(): void { @@ -82,9 +82,9 @@ public function testTrackPluginActivationDoesNotRecordEventIfPluginIsNotRDB(): v $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 0 ) )->method( 'record_event' ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_plugin_activation( 'plugin_path' ); + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_plugin_activation( 'plugin_path' ); } public function testTrackPluginActivationDoesRecordEventIfPluginIsRDB(): void { @@ -96,9 +96,9 @@ public function testTrackPluginActivationDoesRecordEventIfPluginIsRDB(): void { $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 1 ) )->method( 'record_event' )->with( 'remotedatablocks_plugin_toggle', $this->isType( 'array' ) ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_plugin_activation( 'plugin_path' ); + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_plugin_activation( 'plugin_path' ); } public function testTrackPluginDeactivationDoesNotRecordEventIfPluginIsNotRDB(): void { @@ -110,9 +110,9 @@ public function testTrackPluginDeactivationDoesNotRecordEventIfPluginIsNotRDB(): $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 0 ) )->method( 'record_event' ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_plugin_deactivation( 'plugin_path' ); + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_plugin_deactivation( 'plugin_path' ); } public function testTrackPluginDeactivationDoesRecordEventIfPluginIsRDB(): void { @@ -124,9 +124,9 @@ public function testTrackPluginDeactivationDoesRecordEventIfPluginIsRDB(): void $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 1 ) )->method( 'record_event' )->with( 'remotedatablocks_plugin_toggle', $this->isType( 'array' ) ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_plugin_deactivation( 'plugin_path' ); + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_plugin_deactivation( 'plugin_path' ); } public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfUsageShouldNotBeTracked(): void { @@ -138,9 +138,9 @@ public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfUsageShouldNotB $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 0 ) )->method( 'record_event' ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_remote_data_blocks_usage( 1, (object) [] ); + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_remote_data_blocks_usage( 1, (object) [] ); } public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfPostStatusIsNotPublish(): void { @@ -152,9 +152,9 @@ public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfPostStatusIsNot $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 0 ) )->method( 'record_event' ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_remote_data_blocks_usage( 1, (object) [ 'post_status' => 'draft' ] ); + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_remote_data_blocks_usage( 1, (object) [ 'post_status' => 'draft' ] ); } public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfPostContentHaveNoRemoteBlocks(): void { @@ -179,9 +179,9 @@ public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfPostContentHave $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 0 ) )->method( 'record_event' ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_remote_data_blocks_usage( 1, (object) [ + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_remote_data_blocks_usage( 1, (object) [ 'post_type' => 'post', 'post_status' => 'publish', 'post_content' => '

No remote data blocks

', @@ -236,9 +236,9 @@ public function testTrackRemoteDataBlocksUsageDoesTrackEventIfPostContentHaveRem 'generic-http_data_source_count' => 1, ] ); - set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); - TracksAnalytics::init( $env_config_mock ); - TracksAnalytics::track_remote_data_blocks_usage( 1, (object) [ + set_private_property( TracksTelemetry::class, null, 'instance', $tracks_mock ); + TracksTelemetry::init( $env_config_mock ); + TracksTelemetry::track_remote_data_blocks_usage( 1, (object) [ 'post_type' => 'post', 'post_status' => 'publish', 'post_content' => '

Tonal Accessories Shelf (Coffee Oak)

Our floating shelf is the perfect way to store all your Tonal accessories. Its sleek, versatile design makes this an easy fit with any style room. Available in Coffee Oak (seen here), as well as Matte Black and Light Aged Ash. Made in the U.S.

$272.99

T-Locks (Pack of 4)

No detail is too small. Tonal’s proprietary T-Locks let you swap out Tonal accessories with a quick push and twist to lock everything in place.

$42.99

Break

Pearl room

Panel

', @@ -246,8 +246,8 @@ public function testTrackRemoteDataBlocksUsageDoesTrackEventIfPostContentHaveRem } public function testRecordEventDoesNothingIfInstanceIsNotSet(): void { - /** @var TracksAnalytics|MockObject */ - $obj = new TracksAnalytics(); + /** @var TracksTelemetry|MockObject */ + $obj = new TracksTelemetry(); $result = $obj->record_event( 'name', [] ); @@ -256,10 +256,10 @@ public function testRecordEventDoesNothingIfInstanceIsNotSet(): void { public function testRecordEventTracksTheEventIfInstanceIsSet(): void { $mock_tracks = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); - $mock_tracks->expects( $this->exactly( 1 ) )->method( 'record_event' )->with( 'event_name', [ 'event_props' ] ); - /** @var TracksAnalytics|MockObject */ - $obj = new TracksAnalytics(); - set_private_property( TracksAnalytics::class, $obj, 'instance', $mock_tracks ); + $mock_tracks->expects( $this->exactly( 1 ) )->method( 'record_event' )->with( 'remotedatablocks_event_name', [ 'event_props' ] ); + /** @var TracksTelemetry|MockObject */ + $obj = new TracksTelemetry(); + set_private_property( TracksTelemetry::class, $obj, 'instance', $mock_tracks ); $result = $obj->record_event( 'event_name', [ 'event_props' ] ); @@ -267,16 +267,16 @@ public function testRecordEventTracksTheEventIfInstanceIsSet(): void { } public function testResetMethod(): void { - $obj = new TracksAnalytics(); - set_private_property( TracksAnalytics::class, $obj, 'instance', new MockTracks() ); - TracksAnalytics::init( new EnvironmentConfig() ); + $obj = new TracksTelemetry(); + set_private_property( TracksTelemetry::class, $obj, 'instance', new MockTracks() ); + TracksTelemetry::init( new EnvironmentConfig() ); - $this->assertInstanceOf( MockTracks::class, TracksAnalytics::get_instance() ); - $this->assertInstanceOf( EnvironmentConfig::class, TracksAnalytics::get_env_config() ); + $this->assertInstanceOf( MockTracks::class, TracksTelemetry::get_instance() ); + $this->assertInstanceOf( EnvironmentConfig::class, TracksTelemetry::get_env_config() ); - TracksAnalytics::reset(); + TracksTelemetry::reset(); - $this->assertEquals( null, TracksAnalytics::get_instance() ); - $this->assertEquals( null, TracksAnalytics::get_env_config() ); + $this->assertEquals( null, TracksTelemetry::get_instance() ); + $this->assertEquals( null, TracksTelemetry::get_env_config() ); } } diff --git a/tests/inc/stubs.php b/tests/inc/stubs.php index ef92dce2..023b63b9 100644 --- a/tests/inc/stubs.php +++ b/tests/inc/stubs.php @@ -17,6 +17,10 @@ function esc_html( string $text ): string { return $text; } +function esc_html__( string $text ): string { + return apply_filters( 'esc_html__', $text ); +} + function register_block_pattern( string $_name, array $_options ): void { // Do nothing } diff --git a/tests/src/blocks/remote-data-container/components/modals/InputModal.test.tsx b/tests/src/blocks/remote-data-container/components/modals/InputModal.test.tsx index 9c031ba9..c8617297 100644 --- a/tests/src/blocks/remote-data-container/components/modals/InputModal.test.tsx +++ b/tests/src/blocks/remote-data-container/components/modals/InputModal.test.tsx @@ -66,7 +66,7 @@ describe( 'InputModal', () => { input1: 'Test Value 1', input2: 'Test Value 2', } ); - expect( sendTracksEvent ).toHaveBeenCalledWith( 'remotedatablocks_add_block', { + expect( sendTracksEvent ).toHaveBeenCalledWith( 'add_block', { action: 'select_item', data_source_type: '', selected_option: 'manual_input', diff --git a/tests/src/utils/input-validation.test.ts b/tests/src/utils/input-validation.test.ts new file mode 100644 index 00000000..ac990f05 --- /dev/null +++ b/tests/src/utils/input-validation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { isQueryInputValid, validateQueryInput } from '@/utils/input-validation'; + +describe( 'validateQueryInput', () => { + it( 'should validate query input', () => { + const queryInput: RemoteDataQueryInput = { + input1: 'value1', + input2: 'value2', + }; + const inputVariables: InputVariable[] = [ + { slug: 'input1', required: true, type: 'string' }, + { slug: 'input2', required: true, type: 'string' }, + { slug: 'input3', required: false, type: 'string' }, + ]; + + expect( () => validateQueryInput( queryInput, inputVariables ) ).not.toThrow(); + expect( isQueryInputValid( queryInput, inputVariables ) ).toBe( true ); + } ); + + it( 'should throw an error if query input is missing required variables', () => { + const queryInput: RemoteDataQueryInput = { + input1: 'value1', + }; + const inputVariables: InputVariable[] = [ + { slug: 'input1', required: true, type: 'string' }, + { slug: 'input2', required: true, type: 'string' }, + ]; + + expect( () => validateQueryInput( queryInput, inputVariables ) ).toThrowError( + 'Missing required query input variables' + ); + expect( isQueryInputValid( queryInput, inputVariables ) ).toBe( false ); + } ); + + it( 'should not throw an error for non-nullish but falsy values', () => { + const queryInput: RemoteDataQueryInput = { + input1: '', + input2: false, + }; + const inputVariables: InputVariable[] = [ + { slug: 'input1', required: true, type: 'string' }, + { slug: 'input2', required: true, type: 'boolean' }, + ]; + + expect( validateQueryInput( queryInput, inputVariables ) ).toBe( true ); + expect( isQueryInputValid( queryInput, inputVariables ) ).toBe( true ); + } ); +} ); diff --git a/types/localized-block-data.d.ts b/types/localized-block-data.d.ts index ec8af1f9..368eacd3 100644 --- a/types/localized-block-data.d.ts +++ b/types/localized-block-data.d.ts @@ -1,10 +1,17 @@ type RemoteDataBinding = Pick< RemoteDataResultFields, 'name' | 'type' >; type AvailableBindings = Record< string, RemoteDataBinding >; +/** + * This corresponds directly to the input schema defined by a query. + */ interface InputVariable { - name: string; + /** The display friendly name of the variable */ + name?: string; + /** Whether the variable is required, or not in the query */ required: boolean; + /** The slug of the variable in the query */ slug: string; + /** The type of the variable in the query */ type: string; }