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/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 75%
rename from inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php
rename to inc/Integrations/SalesforceD2C/SalesforceD2CIntegration.php
index c3be7d22..9b2949c5 100644
--- a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php
+++ b/inc/Integrations/SalesforceD2C/SalesforceD2CIntegration.php
@@ -1,39 +1,38 @@
REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE,
+ 'service' => REMOTE_DATA_BLOCKS_SALESFORCE_D2C_SERVICE,
'enable_blocks' => true,
] );
foreach ( $data_source_configs as $config ) {
- $data_source = SalesforceB2CDataSource::from_array( $config );
+ $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']
);
@@ -51,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']
);
},
@@ -61,6 +60,7 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr
'product_id' => [
'name' => 'Product ID',
'type' => 'id',
+ 'required' => true,
],
],
'output_schema' => [
@@ -73,27 +73,22 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr
],
'name' => [
'name' => 'Name',
- 'path' => '$.name',
- 'type' => 'string',
- ],
- 'longDescription' => [
- 'name' => 'Long Description',
- 'path' => '$.longDescription',
+ 'path' => '$.fields.Name',
'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',
],
],
@@ -104,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',
],
],
@@ -146,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(
@@ -189,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 @@
\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/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/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/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/remote-data-blocks.php b/remote-data-blocks.php
index 2b94f995..d65e5794 100644
--- a/remote-data-blocks.php
+++ b/remote-data-blocks.php
@@ -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/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 2b0abe44..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,15 +39,7 @@ 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 );
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/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/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 d8d80c85..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';
diff --git a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx
index 029f0db5..1867010a 100644
--- a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx
+++ b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx
@@ -1,4 +1,4 @@
-import { TextareaControl, SelectControl } from '@wordpress/components';
+import { SelectControl, TextareaControl } from '@wordpress/components';
import { useEffect, useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
@@ -7,16 +7,16 @@ import { FieldsSelection } from '@/data-sources/components/FieldsSelection';
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';
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/salesforce-b2c/SalesforceB2CSettings.tsx b/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx
deleted file mode 100644
index 9a75dd09..00000000
--- a/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx
+++ /dev/null
@@ -1,117 +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 { ConfigSource } from '@/data-sources/constants';
-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,
- config_source: ConfigSource.STORAGE,
- };
-
- 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/types.ts b/src/data-sources/types.ts
index 729d19b9..1f6d97b1 100644
--- a/src/data-sources/types.ts
+++ b/src/data-sources/types.ts
@@ -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/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 = (