The Background
In the prior three posts on Guided Selling, and Product Configuration Initializer, we reviewed some alternative approaches to configuration using Salesforce CPQ. We then looked at Product Search Executor, which is currently blocked by an apparent bug (or misunderstanding on my part)– I have a case open to investigate this further, and will update that post when resolved. Now we move to the Product Search Plugin, which is the most advanced and flexible type of extension for search.
Scenario Review
We will continue with our example of configuring a Gaming Laptop using Guided Selling. In this example, we’ll use the Product Search Plugin as an extension to the Guided Selling suggestion, and filter out Legacy products under suggestions. There are nearly endless possibilities here, so the goal is to demonstrate the structure and basic application so that you can extend the logic for your business scenario. The developer documentation is pretty basic at this point and following it directly will not allow the plugin to function, so we need to explore this a bit and uncover the ‘gotchas’.
Additional Product Setup
If you performed the Product Search Executor demo, you’ll have done some of this already. To demonstrate the concept, we’ll clone the ‘Gaming Laptop’ and create a ‘Gaming Laptop Old’ and set Active = false. Don’t forget to also add a Pricebook Entry so that the laptop will show up in selection. We will also add a new Product Family of ‘Legacy’ to differentiate between old and new models, and only allow new quotes to see new Laptops and not those maintained for legacy purposes. Update the Gaming Laptop Old to have Product Family = ‘Legacy’.
When we go into our quote and enter the Guided Selling prompts to arrive at the Gaming Laptop, we’ll now see that we have two Gaming Laptops available. While there are obviously many ways to further filter, we’ll use the Product Search Plugin to resolve this.
Process Input Adjustments
In previous posts we named our Process Inputs with more descriptive titles that did not match the underlying field’s api name. This will cause some grief in the Product Search Plugin, and prevent the full search method from working as written in the developer documentation. This is due to the fieldValueMap that is used in the implementation– it reads the Input Name and uses it in building the dynamic SOQL used in the full suggest/search. We will thus adjust ‘Mobility Check’ => ‘Mobility__c’ and ‘Purpose Check’ => ‘Purpose__c’ as shown below.
Create Product Search Plugin – Background and Gotchas
Unfortunately the developer documentation doesn’t cover all the required implementation methods properly, so we need to derive some on our own by messing around with it. Additionally there are a few mistakes in the example methods and we’ll need to account for those. Let’s do that first.
The first note is that in the documentation there are a couple missing methods that are used for Guided Selling. Until you add these you’ll get an error that all interface methods haven’t been implemented:
- ‘suggest’ method needs to be paired with ‘search’ method
- Additionally, ‘getAdditionalSuggestFilters’ is paired with ‘getAdditionalSearchFilters’.
The second note is that ‘Price BookEntry’ is referenced multiple times in search() method – suggest() method is missing as referenced above. Of course this should be PriceBookEntry, so make sure to search and replace these first thing.
The third note is that in the Product there are field sets for Search Filter and Search Results. If you want the inputs from Guided Selling to be applied in the full suggest(), then you need to get the Input field name and add to the Search Filter field set. Otherwise, it will be bypassed while building the dynamic SOQL.
The last note is that the WHERE clause in the dynamic SOQL references field ‘SBQQ__Price book__c’ and then uses it as the Id filter for the PriceBookEntry search as shown below.
This leads to a null value in the Id filter, because that field does not exist. Replace it with ‘SBQQ__PricebookId__c’ instead and this will be resolved.
As you will see from the documentation, there are a few different method calls used to orchestrate the search filter behavior. For our implementation, we’ll be using the ‘Enhanced’ search to extend our Guided Selling example. There is also a ‘Custom’ search option which enables full control over the search for where we aren’t already searching based on Guided Selling inputs. We will quickly demonstrate how we can check and call the full suggest in the case that Guided Selling inputs aren’t included. Note that if you are not using Guided Selling, the approach is very similar, but the methods accessed will be for search filters and full search as opposed to the suggest filters and full suggest.
Product Search Plugin – Setup
Now we’ll put together the basics of the class…here called ‘TestProductSearch’. This is basically a copy/paste from developer documents, but adding some custom logic and fixing the issues noted above.
global class TestProductSearch implements SBQQ.ProductSearchPlugin{
//Constructor not required
global TestProductSearch(){
System.debug('Constructor Entered for Test Product Search');
}
//Required methods for this interface
global Boolean isFilterHidden(SObject quote, String fieldName){
System.debug('METHOD CALLED: isFilterHidden');
/*
// This would hide Product Code filter if Quote Status is Approved
return fieldName == 'ProductCode' && quote.SBQQ__Status__c == 'Approved';
*/
return false;
}
global String getFilterDefaultValue(SObject quote, String fieldName){
System.debug('METHOD CALLED: getFilterDefaultValue');
/*
// This would set Product Family filter to Service if Quote Type is Quote
return (fieldName == 'Family' && quote.SBQQ__Type__c == 'Quote') ? 'Service' : NULL;
*/
return NULL;
}
global Boolean isSearchCustom(SObject quote, Map<String,Object> fieldValuesMap){
/*
// This would use CUSTOM mode if a Search field for sorting was defined and used
return fieldValuesMap.get('Sort_By__c') != '';
*/
return false;
}
global Boolean isInputHidden(SObject quote, String input){
System.debug('METHOD CALLED: isInputHidden');
/*
// This would hide an Input called 'Urgent Shipment' on Fridays.
return input == 'Urgent Shipment' && Datetime.now().format('F') == 5;
*/
return false;
}
global String getInputDefaultValue(SObject quote, String input){
System.debug('METHOD CALLED: getInputDefaultValue');
//Get Default value for input if available.
return NULL;
}
global Boolean isSuggestCustom(SObject quote, Map<String,Object> inputValuesMap){
System.debug('METHOD CALLED: isSuggestCustom');
return inputValuesMap.get('Purpose__c) == null;
}
global String getAdditionalSearchFilters(SObject quote, Map<String,Object> fieldValuesMap){
System.debug('METHOD CALLED: getAdditionalSearchFilters');
//Won't use for guided selling.
return NULL;
}
global String getAdditionalSuggestFilters(SObject quote, Map<String,Object> fieldValuesMap){
String additionalFilter = NULL;
//If gaming laptop, then we only offer the latest and greatest (Not Legacy)
if(fieldValuesMap.get('Purpose__c') == 'Gaming'){
additionalFilter = 'Product2.Family = \'Hardware\'';
}
return additionalFilter;
}
global List<PriceBookEntry> search(SObject quote, Map<String,Object> fieldValuesMap){
System.debug('METHOD CALLED: search');
//Won't use for guided selling.
List<PriceBookEntry> pbes = new List<PriceBookEntry>();
return pbes;
}
global List<PriceBookEntry> suggest(SObject quote, Map<String,Object> fieldValuesMap){
System.debug('METHOD CALLED: suggest');
//GET ALL POSSIBLE FILTER FIELDS FROM THE SEARCH FILTER FIELD SET
List<Schema.FieldSetMember> searchFilterFieldSetFields = SObjectType.Product2.FieldSets.SBQQ__SearchFilters.getFields();
//GET ALL POSSIBLE FIELDS FROM THE SEARCH RESULTS FIELD SET
List<Schema.FieldSetMember> searchResultFieldSetFields = SObjectType.Product2.FieldSets.SBQQ__SearchResults.getFields();
//BUILD THE SELECT STRING
String selectClause = 'SELECT ';
for(Schema.FieldSetMember field : searchResultFieldSetFields){
selectClause += 'Product2.' + field.getFieldPath() + ', ';
}
selectClause += 'Id, UnitPrice, PriceBook2Id, Product2Id, Product2.Id';
System.debug('select clause: '+selectClause);
//BUILD THE WHERE CLAUSE
String whereClause = '';
for(Schema.FieldSetMember field : searchFilterFieldSetFields){
if(!fieldValuesMap.containsKey(field.getFieldPath())){
continue;
}
if(field.getType() == Schema.DisplayType.String || field.getType() == Schema.DisplayType.Picklist){
whereClause += 'Product2.' + field.getFieldPath() + ' LIKE \'%' + fieldValuesMap.get(field.getFieldPath()) + '%\' AND ';
}
}
//Add Hardware Filter for demo purposes
whereClause += 'Product2.Family = \'Hardware\' AND ';
whereClause += 'PriceBook2Id = \'' + quote.get('SBQQ__PriceBookId__c') + '\'';
//BUILD THE QUERY
String query = selectClause + ' FROM PriceBookEntry WHERE ' + whereClause;
//DO THE QUERY
List<PriceBookEntry> pbes = new List<PriceBookEntry>();
pbes = Database.query(query);
return pbes;
}
}
Product Search Plugin — Reference in Configuration
Unlike the Product Configuration Initializer and Product Search Executor, the Product Search Plugin is a class only and is maintained in the managed package configuration settings for Salesforce CPQ. Once it’s created, plug in the value as so:
Product Search Plugin – Additional Suggestions for Guided Selling
In our example, we’ll demonstrate the additional filtering on the back of Guided Selling by checking the purpose input provided during Guided Selling. If that is ‘Gaming’ then we want to filter out the Old version of the laptop (Family = ‘Legacy’) by limiting to ‘Hardware’ only.
global String getAdditionalSuggestFilters(SObject quote, Map<String,Object> fieldValuesMap){
String additionalFilter = NULL;
//If gaming laptop, then we only offer the latest and greatest (Not Legacy)
if(fieldValuesMap.get('Purpose__c') == 'Gaming'){
additionalFilter = 'Product2.Family = \'Hardware\'';
}
return additionalFilter;
}
Here we are doing a simple check to see if Purpose__c (renamed from Purpose Check in earlier step) to see if it’s Gaming. If it is, then we want to apply an additional filter in the background that the Family must be ‘Hardware’, which will block out ‘Legacy’ used on our Old Laptop.
Testing Out the Additional Filter
To test out this change, let’s go to our Quote and Add Products. Remember that prior to the change we were seeing 2x Laptops in the results of selecting Mobile + Gaming. Now we make the same selection.
Now we only get the one return, and if we have auto-select enabled still from the previous posts, then we are immediately taken back to the QLE with the Product added.
We have successfully added an additional filter on the backend of Guided Selling!
Product Search Plugin – Full Suggest where Guided Selling is Not Filled
We will now do a simple example of checking Custom vs. Enhanced and then into a full suggest if now Custom is true (previous example it was false). Here we’ll assume that if Purpose is not selected, then Guided Selling was not properly used and we want to enter custom Suggest with what inputs we have received. This is not a good real-life example as Guided selling will do that for us anyways, but here we’ll also filter out any ‘Legacy’ products just to demonstrate that the fully custom Suggest is indeed being used, and can be employed in much more complicated scenarios.
First we use the isSuggestCustom() method to return ‘true’ if the Purpose__c input is not filled.
global Boolean isSuggestCustom(SObject quote, Map<String,Object> inputValuesMap){
System.debug('METHOD CALLED: isSuggestCustom');
return inputValuesMap.get('Purpose__c') == null;
}
Because the isSuggestCustom() evaluated true, the CPQ engine will now call the suggest() method instead of getAdditionalSuggestFilters() as it did previously. Here we are highlighting a few things in addition to the field set updates mentioned previously. First, notice that WHERE clause is being updated with any Guided Selling process inputs entered for Mobility or Purpose inputs. So, in other words, we retain any partial inputs we may have gotten on the Mobility entry (as we’re heading into full suggest if Purpose was left empty). Additionally, note that line where we’re checking that Product2.Family == ‘Hardware’ to filter out our Old Laptop where family = ‘Legacy’. If we leave this out, we’ll see the Old Laptop as well.
global List<PriceBookEntry> suggest(SObject quote, Map<String,Object> fieldValuesMap){
System.debug('METHOD CALLED: suggest');
//GET ALL POSSIBLE FILTER FIELDS FROM THE SEARCH FILTER FIELD SET
List<Schema.FieldSetMember> searchFilterFieldSetFields = SObjectType.Product2.FieldSets.SBQQ__SearchFilters.getFields();
//GET ALL POSSIBLE FIELDS FROM THE SEARCH RESULTS FIELD SET
List<Schema.FieldSetMember> searchResultFieldSetFields = SObjectType.Product2.FieldSets.SBQQ__SearchResults.getFields();
//BUILD THE SELECT STRING
String selectClause = 'SELECT ';
for(Schema.FieldSetMember field : searchResultFieldSetFields){
selectClause += 'Product2.' + field.getFieldPath() + ', ';
}
selectClause += 'Id, UnitPrice, PriceBook2Id, Product2Id, Product2.Id';
System.debug('select clause: '+selectClause);
//BUILD THE WHERE CLAUSE
String whereClause = '';
for(Schema.FieldSetMember field : searchFilterFieldSetFields){
if(!fieldValuesMap.containsKey(field.getFieldPath())){
continue;
}
if(field.getType() == Schema.DisplayType.String || field.getType() == Schema.DisplayType.Picklist){
whereClause += 'Product2.' + field.getFieldPath() + ' LIKE \'%' + fieldValuesMap.get(field.getFieldPath()) + '%\' AND ';
}
}
//Add Hardware Filter for demo purposes
whereClause += 'Product2.Family = \'Hardware\' AND ';
whereClause += 'PriceBook2Id = \'' + quote.get('SBQQ__PriceBookId__c') + '\'';
//BUILD THE QUERY
String query = selectClause + ' FROM PriceBookEntry WHERE ' + whereClause;
//DO THE QUERY
List<PriceBookEntry> pbes = new List<PriceBookEntry>();
pbes = Database.query(query);
return pbes;
}
Testing it Out – Full Suggest/Search
For this test, let’s set Mobile and leave Purpose empty.
Without our custom suggest, we’d see 3 results on Suggest as we’d see the Products with Mobility__c = ‘Mobile’. But because we added the ‘Hardware’ family check in the custom suggest, we’ll only get the Gaming and Work laptops.
This is easily seen in debug log as we can see the suggest() method called due to Purpose being empty (isCustomSuggest returning false as a result).
Success on the input check and custom full suggest/search!
Other Notes
See in the developer documentation that they’ve given an example of how this could be employed in more creative ways. In that note, they’re sorting the product search to return in descending order the Products ordered by the customer most recently. For example, query the Order Products for the Account on the Quote, and get the Product Ids and dates to use in the filter or sort. This could be a powerful way to show the most relevant products on a customer by customer basis, and there is a demonstration of it from TrailheadDX a while back (~21 minutes in).
Hopefully this gives you some tips on how to employ the Product Search Plugin and gets you over the initial barriers to entry on using it. I can think of a ton of different use cases now that we’re there. Thanks for reading!
[…] is a quick review of a scenario posed by Tejas Kapasi in response to the last post on the basics of Product Search Plugin. He questioned whether or not the Product Search Plugin could be employed to prevent adding a […]
[…] Product Search Plugin […]
We’ve utilized both suggest and search in our plugin and are adding parameters for the final sort of search results. The issue I’m having is that, when I use guided selling the results are filtered by my quote process inputs as expected, but not sorted in the order we expect. Then, if the user enters custom search value, the search results are filtered only by their search term (not the guided selling inputs) but they are sorted in the correct order. Any ideas why this would be happening?