From f920e796d2d727bcb9c75adca1790fa0a601e9a8 Mon Sep 17 00:00:00 2001 From: Gopal Krishnan Date: Fri, 21 Feb 2025 12:03:31 +1100 Subject: [PATCH] Add Salesforce D2C as a data source (#372) * Replace Salesforce b2c with d2c * Fix a missing product_id field * Skip empty search queries, and map the name correctly * Use the constant instead of the hardcoded value * Use null coalescing Co-authored-by: Chris Zarate * Safety when returning the token Co-authored-by: Chris Zarate * Remove the unnecessary memo * Attempt to only fire the fetch for required variables * Got the search and productId queries working * Fix the search and productId lookups * Add support for token expiry * Strengthen the empty query checks * Simplify the search query check, fix the expiry time and add more comments * Add an additional layer of checks for subsequent fetches * Fix the search functionality, and ensure missing required variables don't fire queries * Refactor the salesforce setup screen to be a lot more user friendly * Move the auth call to the server and simplify the setup page * Fix the docblock and the unsafe use of wp_remote_get * Center-align "Salesforce D2C" text in "Connect New" menu * Add error state and fix bugs in useRemoteData * Fix type error: Names are optional * Separate logic for initial fetch and refetch * Fix comment * PR Feedback * Add more validation for the domain of the salesforce url --------- Co-authored-by: Chris Zarate Co-authored-by: Alec Geatches --- docs/extending/block-registration.md | 2 +- inc/Config/QueryRunner/QueryRunner.php | 5 + .../SalesforceB2C/Auth/SalesforceB2CAuth.php | 247 ----------------- .../SalesforceD2C/Auth/SalesforceD2CAuth.php | 200 ++++++++++++++ .../SalesforceD2CDataSource.php} | 12 +- .../SalesforceD2CIntegration.php} | 65 ++--- .../assets/salesforce_commerce_cloud_logo.png | Bin .../templates/block_registration.template | 22 +- inc/Integrations/constants.php | 6 +- inc/REST/AuthController.php | 60 ++++- inc/Snippet/Snippet.php | 12 +- inc/Validation/ConfigSchemas.php | 1 + remote-data-blocks.php | 2 +- .../FieldShortcodeSelectNew.tsx | 1 - .../components/modals/DataViewsModal.tsx | 24 +- .../hooks/useRemoteData.ts | 94 ++++++- .../hooks/useSearchVariables.ts | 11 +- src/data-sources/DataSourceSettings.tsx | 10 +- src/data-sources/api-clients/auth.ts | 21 ++ src/data-sources/api-clients/google.ts | 8 +- .../components/AddDataSourceDropdown.tsx | 10 +- src/data-sources/constants.ts | 6 +- .../google-sheets/GoogleSheetsSettings.tsx | 6 +- .../hooks/useSalesforceD2CAuth.ts | 34 +++ .../salesforce-b2c/SalesforceB2CSettings.tsx | 117 -------- .../salesforce-d2c/SalesforceD2CSettings.tsx | 254 ++++++++++++++++++ src/data-sources/types.ts | 29 +- ...Icon.tsx => SalesforceCommerceD2CIcon.tsx} | 4 +- src/settings/index.scss | 7 +- src/utils/input-validation.ts | 60 +++++ tests/src/utils/input-validation.test.ts | 49 ++++ types/localized-block-data.d.ts | 9 +- 32 files changed, 881 insertions(+), 507 deletions(-) delete mode 100644 inc/Integrations/SalesforceB2C/Auth/SalesforceB2CAuth.php create mode 100644 inc/Integrations/SalesforceD2C/Auth/SalesforceD2CAuth.php rename inc/Integrations/{SalesforceB2C/SalesforceB2CDataSource.php => SalesforceD2C/SalesforceD2CDataSource.php} (71%) rename inc/Integrations/{SalesforceB2C/SalesforceB2CIntegration.php => SalesforceD2C/SalesforceD2CIntegration.php} (75%) rename inc/Integrations/{SalesforceB2C => SalesforceD2C}/assets/salesforce_commerce_cloud_logo.png (100%) rename inc/Integrations/{SalesforceB2C => SalesforceD2C}/templates/block_registration.template (51%) create mode 100644 src/data-sources/hooks/useSalesforceD2CAuth.ts delete mode 100644 src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx create mode 100644 src/data-sources/salesforce-d2c/SalesforceD2CSettings.tsx rename src/settings/icons/{SalesforceCommerceB2CIcon.tsx => SalesforceCommerceD2CIcon.tsx} (99%) create mode 100644 src/utils/input-validation.ts create mode 100644 tests/src/utils/input-validation.test.ts 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 = ( ); -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/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; }