Meet business requirements by adding a custom LWC to a Product Configuration in Salesforce CPQ.
Related References:
- easyXDM Documentation
- Lightning Component in Visualforce Page
- Create Custom Actions for Salesforce CPQ Link to URL
Scenario
Picture a complex configured product where you have many Tabs (Categories) and Features within those Tabs. As the Sales user is configuring the Product, there is not a natively available summary of what has been selected from within the configuration. The user either has to click and scroll endlessly to review selections, or has to Save the configuration and return to the Quote Line Editor. Returning to the QLE will often be the preferred method, but the trouble is that this also does not offer a foolproof summary given that many selections may be hidden from view on the QLE.
How do we provide a consolidated view of current selections from within the Product Configuration itself?
Background
Salesforce CPQ offers a method by which to interact with the configuration data as passed by the package. See the easyXDM instructions. We can use this to collect data from the app, manipulate it (as required) and pass back. In our example, we just need to collect the inputs made so far in the configuration so that we can display it in a summary page.
To add a launch-point to the Product Configuration page, we’ll also need a Custom Action that calls a VF page. In our use case, we’ll use a Lightning:Out App to provide a Lightning Web Component for the component. Of course you can also just use the VF Page instead to provide whatever customization you want.
Solution – Create LWC
This is where you provide whatever UI and logic you want to surface to your users. We would often want to capture information from the Product, PricebookEntries, other related custom objects, etc. In this example, I’m just using the selected Product Options, Pricebook, and Currency to go fetch some Product Option, Product and Pricebook entry attributes – this is just an example, and in real life we will do a lot more here to help the user. It’s also a stripped down version of a more complex implementation, so there is surely a superior way to accomplish this for your use case. Just take the inputs and manipulate to meet your needs.
<template>
<div class="slds-var-m-around_x-small">
<lightning-card title="Selected Options" icon-name="standard:catalog">
<div class="slds-var-m-around_x-small slds-border_top slds-border_bottom
slds-border_right slds-border_left">
<lightning-datatable
data={dataOpt}
columns={columns}
key-field="id"
hide-checkbox-column="true">
</lightning-datatable>
</div>
</lightning-card>
</div>
</template>
import { LightningElement, api, track, wire } from 'lwc';
import getProducts from '@salesforce/apex/ConfigurationSummaryCtrl.getProducts';
const columns = [
{ label: 'Name', fieldName: 'productName' , sortable: true, initialWidth: 300 },
{ label: 'Part Number', fieldName: 'productCode' , sortable: true, initialWidth: 140 },
{ label: 'Qty', fieldName: 'quantity' , sortable: true, initialWidth: 80 },
{ label: 'Feature', fieldName: 'feature' , sortable: true, initialWidth: 180 },
{ label: 'Part Standard', fieldName: 'standardPrice' , type: 'currency' ,
typeAttributes: { currencyCode: { fieldName: 'currencyCode' }, step: '0.001' } , sortable: true },
{ label: 'Line Standard', fieldName: 'lineStandardPrice' , type: 'currency' ,
typeAttributes: { currencyCode: { fieldName: 'currencyCode' }, step: '0.001' } , sortable: true }
];
export default class ConfigurationSummaryLWC extends LightningElement {
@api recordId;
@api productId;
@api productJSON;
@api configAttrJSON;
@api quoteJSON;
@api quoteCurrency;
@api quotePricebook;
@track availableProducts = {};
@track quote = {};
@track configAttributes = {};
@track allProducts = [];
@track finalProducts = [];
@track selectedProducts = [];
@track dataOpt = [];
@track error;
@track listPO = [];
columns = columns;
//parse inbound records
connectedCallback(){
if(this.productJSON != undefined){
this.availableProducts = JSON.parse(this.productJSON);
}
if(this.quoteJSON != undefined){
this.quote = JSON.parse(this.quoteJSON);
}
if(this.configAttrJSON != undefined){
this.configAttributes = JSON.parse(this.configAttrJSON);
}
for (const [key, value] of Object.entries(this.availableProducts)) {
this.allProducts.push(value);
}
this.finalProducts = [].concat.apply([], this.allProducts);
this.selectedProducts = this.finalProducts.filter( x =>
x.selected === true
);
for(let i=0; i < this.selectedProducts.length; i++){
let line = {};
line.Id = this.selectedProducts[i].optionId;
line.sObjectType = 'SBQQ__ProductOption__c';
line.SBQQ__Quantity__c = this.selectedProducts[i].Quantity;
line.SBQQ__ProductCode__c = this.selectedProducts[i].ProductCode;
this.listPO.push(line);
}
this.getProductQuery();
}
getProductQuery(){
getProducts({currencyCode: this.quoteCurrency,
pricebookId: this.quotePricebook, country: this.quoteCountry,
listPO : this.listPO})
.then(result => {
if(result.length > 0){
this.dataOpt = result;
}
})
.catch(error => {
console.log('error: '+JSON.stringify(error));
})
.finally(() => {});
}
}
Solution – Create Controller for LWC
Create the matching controller in Apex. Here we’re collecting the information from Product Option, Product and PricebookEntry. This is a stripped down example from something far more complex, so isn’t necessarily the best way to gather only this information. It’s also meant to pick up where selections were made for dynamic bundles – notice the piece where we are collecting ‘fake’ Product Option Ids. If that’s not a relevant use case for you, just ignore all that part and focus on the overall point, which is just collecting whatever extension information you want to add to selected products.
public with sharing class ConfigurationSummaryCtrl {
@AuraEnabled(cacheable=false)
public static List<SumWrapper> getProducts(String currencyCode,
Id pricebookId, String country,
List<SBQQ__ProductOption__c> listPO){
Set<String> setProdCode = new Set<String>();
Map<Id,SBQQ__ProductOption__c> mapId2PO = new Map<Id,SBQQ__ProductOption__c>();
Map<Id,SumWrapper> mapProd2Wrapper = new Map<Id,SumWrapper>();
Map<String,SumWrapper> mapPo2Wrapper = new Map<String,SumWrapper>();
Map<String,SumWrapper> mapProdCode2Wrapper = new Map<String,SumWrapper>();
for(SBQQ__ProductOption__c po : listPO){
mapId2PO.put(po.Id,po);
}
for(SBQQ__ProductOption__c po : [SELECT Id, SBQQ__UnitPrice__c,
SBQQ__OptionalSKU__r.Name,
SBQQ__ProductCode__c, SBQQ__Feature__r.Name,
SBQQ__ConfiguredSKU__r.Name,
SBQQ__ConfiguredSKU__r.ProductCode,
SBQQ__Feature__r.SBQQ__Category__c
FROM SBQQ__ProductOption__c
WHERE Id IN :mapId2PO.keySet()]){
SumWrapper sw = new SumWrapper();
sw.productId = po.SBQQ__OptionalSKU__c;
sw.poId = po.Id;
sw.quantity = mapId2PO.get(sw.poId).SBQQ__Quantity__c; //preserve quantity from input
sw.currencyCode = currencyCode;
sw.productCode = po.SBQQ__ProductCode__c;
sw.productName = po.SBQQ__OptionalSKU__r.Name;
sw.feature = po.SBQQ__Feature__r.Name;
sw.category = po.SBQQ__Feature__r.SBQQ__Category__c;
sw.standardPrice = 0; //overriden later if PBE found.
mapProd2Wrapper.put(sw.productId,sw);
mapPo2Wrapper.put(sw.poId,sw);
}
//figure out which product options where false Ids. These are dynamic bundle selections
for(Id poId : mapId2PO.keySet()){
//if not added to PO wrapper above, then we have a false Id and need Product Code
if(mapPo2Wrapper.get(poId) == null){
setProdCode.add(mapId2PO.get(poId).SBQQ__ProductCode__c); //collection of Product Code
SumWrapper newSw = new SumWrapper();
newSw.quantity = mapId2PO.get(poId).SBQQ__Quantity__c;
newSw.productCode = mapId2PO.get(poId).SBQQ__ProductCode__c;
mapProdCode2Wrapper.put(newSw.productCode,newSw);
}
}
for(PricebookEntry pbe : [SELECT Id, UnitPrice, Product2.Name, ProductCode,
CurrencyIsoCode
FROM PricebookEntry
WHERE Pricebook2Id = :pricebookId
AND IsActive = true
AND Product2.IsActive = true
AND CurrencyIsoCode = :currencyCode
AND (Product2Id IN :mapProd2Wrapper.keySet()
OR ProductCode IN :setProdCode)]){
SumWrapper sw = mapProd2Wrapper.get(pbe.Product2Id);
if(sw != null){
sw.standardPrice = pbe.UnitPrice;
sw.lineStandardPrice = sw.standardPrice * sw.quantity;
}else{
SumWrapper newSW = mapProdCode2Wrapper.get(pbe.ProductCode);
newSW.productId = pbe.Product2Id;
newSW.productName = pbe.Product2.Name;
newSW.currencyCode = currencyCode;
newSW.standardPrice = pbe.UnitPrice;
mapProd2Wrapper.put(newSW.productId,newSW);
}
}
return mapProd2Wrapper.values();
}
//wrapper class to build custom table for display in catalog
public class SumWrapper {
@AuraEnabled
public Id productId {get;set;}
@AuraEnabled
public Id poId {get;set;}
@AuraEnabled
public String productCode {get;set;}
@AuraEnabled
public String productName {get;set;}
@AuraEnabled
public Decimal standardPrice {get;set;}
@AuraEnabled
public Decimal lineStandardPrice {get;set;}
@AuraEnabled
public String currencyCode {get;set;}
@AuraEnabled
public String feature {get;set;}
@AuraEnabled
public String category {get;set;}
@AuraEnabled
public Decimal quantity {get;set;}
}
}
Solution – Create Aura App
Create an Aura Application for use in the VF Page to surface the Lightning Web Component.
<aura:application extends="ltng:outApp" >
<c:configurationSummaryLWC></c:configurationSummaryLWC>
</aura:application>
Solution – Create VF Page
Your VF page will implement the easyXDM.Rpc method as provided in the instructions. It will also create the Lightning Component using the Lightning:Out App created earlier. Note that the message returned from the postMessage should be parsed into a configredObject, and then you can access properties of that message and pass to the @api variables in your LWC. I provided a few examples below past what is actually required for this demo. Log the configuredObject and check all the other options you has as well.
<apex:page sidebar="false" showheader="false">
<head>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/easyXDM/2.4.20/easyXDM.js" />
</head>
<body class="slds-scope">
<apex:includeLightning />
<div id="componentId" />
<script type="text/javascript">
var configuredObject;
var rpc = new easyXDM.Rpc({},{
remote: {
postMessage: {}
},
local: {
postMessage: function(message) {
configuredObject = JSON.parse(message);
}
}
});
$Lightning.use("c:ConfigurationSummaryApp", function() {
$Lightning.createComponent("c:configurationSummaryLWC",
{
recordId : configuredObject.quote.Id,
productId : configuredObject.product.configuredProductId,
configAttrJSON : JSON.stringify(configuredObject.product.configurationAttributes),
productJSON : JSON.stringify(configuredObject.product.optionConfigurations),
quoteJSON : JSON.stringify(configuredObject.quote),
quoteCurrency : configuredObject.quote.CurrencyIsoCode,
quotePricebook : configuredObject.quote.SBQQ__PricebookId__c
},
"componentId",)
});
</script>
</body>
</apex:page>
Message payload. Notice especially the “selected”:true attributes as this tells you which options have been selected.
{
"quote":{
"attributes":{
"type":"SBQQ__Quote__c",
"url":"/services/data/v54.0/sobjects/SBQQ__Quote__c/a0q5e000001f1UDAAY"
},
"AccountIndustry__c":"Healthcare",
"SBQQ__StartDate__c":"2022-02-18",
"SBQQ__NetAmount__c":0,
"SBQQ__CustomerAmount__c":0,
"SBQQ__PaymentTerms__c":"Net 30",
"SBQQ__Opportunity2__r":{
"attributes":{
"type":"Opportunity",
"url":"/services/data/v54.0/sobjects/Opportunity/0065e00000Evq4BAAR"
},
"Id":"0065e00000Evq4BAAR"
},
"SBQQ__Status__c":"Draft",
"SBQQ__LineItemsGrouped__c":false,
"CurrencyIsoCode":"USD",
"SBQQ__ExpirationDate__c":"2022-03-20",
"SBQQ__Account__r":{
"attributes":{
"type":"Account",
"url":"/services/data/v54.0/sobjects/Account/0015e000006jjMfAAI"
},
"Id":"0015e000006jjMfAAI"
},
"SBQQ__ContractingMethod__c":"By Subscription End Date",
"SBQQ__Primary__c":true,
"Name":"Q-00054",
"SBQQ__Type__c":"Quote",
"SBQQ__PricebookId__c":"01s5e000004oqFxAAI",
"SBQQ__Opportunity2__c":"0065e00000Evq4BAAR",
"SBQQ__LineItemCount__c":0,
"Id":"a0q5e000001f1UDAAY",
"SBQQ__Unopened__c":false,
"SBQQ__Account__c":"0015e000006jjMfAAI",
"SBQQ__WatermarkShown__c":false
},
"product":{
"configuredProductId":"01t5e000003ULIvAAO",
"lineItemId":null,
"lineKey":null,
"configurationAttributes":{
"attributes":{
"type":"SBQQ__ProductOption__c"
}
},
"optionConfigurations":{
"Computer":[
{
"optionId":"a0i5e0000040ys6AAA",
"selected":true,
"ProductCode":"Tower",
"ProductName":"Tower",
"Quantity":1,
"configurationData":{},
"readOnly":{}
},
{
"optionId":"a0i5e0000040ysBAAQ",
"selected":false,
"ProductCode":"Laptop",
"ProductName":"Laptop",
"Quantity":1,
"configurationData":{},
"readOnly":{}
}
],
"Games":[
{
"optionId":"a0i5e0000040ysLAAQ",
"selected":true,
"ProductCode":"WorldOfWarcraft",
"ProductName":"World of Warcraft",
"Quantity":1,
"configurationData":{},
"readOnly":{}
},
{
"optionId":"a0i5e0000040ysCAAQ",
"selected":true,
"ProductCode":"Solitaire",
"ProductName":"Solitaire",
"Quantity":1,
"configurationData":{},
"readOnly":{}
}
],
"Operating System":[
{
"optionId":"a0i5e0000040ysQAAQ",
"selected":true,
"ProductCode":"Windows",
"ProductName":"Windows",
"Quantity":1,
"configurationData":{},
"readOnly":{}
},
{
"optionId":"a0i5e0000040ysMAAQ",
"selected":false,
"ProductCode":"Linux",
"ProductName":"Linux",
"Quantity":1,
"configurationData":{},
"readOnly":{}
}
],
"Monitor":[
{
"optionId":"a0i5e0000040ysVAAQ",
"selected":true,
"ProductCode":"24Monitor",
"ProductName":"24\" Monitor",
"Quantity":4,
"configurationData":{},
"readOnly":{}
},
{
"optionId":"a0i5e0000040ysaAAA",
"selected":true,
"ProductCode":"27Monitor",
"ProductName":"27\" Monitor",
"Quantity":2,
"configurationData":{},
"readOnly":{}
}
]
},
"configurationData":{}
},
"products":[],
"readOnly":{},
"redirect":{
"auto":false,
"save":false
}
}
Solution – Create Custom Action
Now we need to create a Custom Action for the Product Configurator. The key here is to select URL Target as Popup, and put in the URL pointing to your custom VF page. Enter your domain and add ‘–‘ before the ‘c.visualforce.com’. See below as an example.
Update 01/11/2023: Thanks to Prateek Arora for noticing this issue. Instead of ‘c.visualforce.com’, if you’re not yet using enhanced domains, or are implementing in a production environment, then use ‘https://yourorg–c.vf.force.com/apex/ConfigurationSummaryVF’. If on enhanced domains in a sandbox, then use ‘https://yourorg–c.sandbox.vf.force.com/apex/ConfigurationSummaryVF’
Further Extensions
There are countless other extensions you can add here, such as specific regionalized pricing you may employ, totaling the values from all columns, giving detailed information based on selections, etc. Get creative as you can now use the information provided by the package to query and build out whatever information your users may require.
Test it Out
Go into a configured product that you’ve set up. Make some adjustments such as selecting options and changing quantities. Next click on ‘Configuration Summary’ Custom Action. Check the result – we now have a table summary of what has been selected so far. This is really useful in the configuration as we no longer have to return to the QLE to see this type of summary. See the limitation below with regard to nested bundles.
Important Limitation
Nested Bundle selection information is not currently available in the payload returned from the package. Therefore, we are not yet able to show selections made more than a level deep. I have heard rumors that this will be addressed in an upcoming release – no safe harbor as I have no inside information, and am rather just guessing!
Wrapping Up
Thanks for reading, and let me know how you use this to solve your business problems.
Credit to my colleagues Shital Bhujbal and Rahul Agarwal who first used a similar approach on a different use case.
Hi!
I have tried exactly what you have done, creating the components and classes but it is not working (It opens an empty pop-up). It is possible to send me a repository to see if I’m creating a component wrong, or I have to change a name or something different. Should I have special permission to access the library in salesforce? Thanks in advance!
Hi Carolina- can you please email me your components and also your custom action URL. Also any CORS settings you have done (not in the article).
Alternatively if you’re using a personal dev box like I am, can set me up as a user so I can check it directly.
[…] of this real-time Product List is a differentiator here as this is only achievable with custom components within standard Salesforce CPQ, and that only within a separate pop-up with suboptimal performance. This real-time feedback to the […]
Hello Nick,
Thank you for the wonderful post .
For suppose, I want to send back some information(config object) from LWC to the Visual force page . Can we use easyXDM to communicate with the Visual force Page,if not what can we use to communicate?
Regards,
Hari D