Hello again! It has been a while since my last post. Unfortunately I have not been able to find time to finish this post until recently. A bit later than I would like, but I hope you find it useful.
Building upon the topic of my previous post on alternatives lookup relationship to Opportunity Products, I would like to build a better interface to add Sub-Items. Using the Lightning Design System and Visualforce Remote Objects I will be building a Visualforce page where users can add, modify, and delete multiple Sub Items related to the Opportunity Product.
Setting up the page initially
I installed the Lightning Design System package onto my org in order to use the LDS css via a static resource.
<apex:page showHeader="true" sidebar="false" docType="html-5.0" standardController="Opportunity_Product_Mirror__c"> <apex:stylesheet value="{!URLFOR($Resource.SLDS090, 'assets/styles/salesforce-lightning-design-system-vf.css')}" /> <apex:includeScript value="https://code.jquery.com/jquery-1.11.3.min.js" /> <body> <div class="slds"> </div> </body> </apex:page>
All LDS markup needs to within the outer div included above so this should be how the page should like like initially. Most of the content will be within those div tags. Since Sub Items are actually a child object to the Opportunity Product Mirror object we will be setting the standard controller for OPM.
Setting up visualforce remote objects
In order query, insert, update, and delete sub items we will be using visualforce remote objects. I find it to be an useful tool if the custom interface being built only requires simple CRUD and query operations that will not be handling large amount of records. If you are thinking about more complex logic I suggest looking into Visualforce Remote Actions instead so you can leverage Apex.
<apex:page showHeader="true" sidebar="false" docType="html-5.0" standardController="Opportunity_Product_Mirror__c"> <apex:stylesheet value="{!URLFOR($Resource.SLDS090, 'assets/styles/salesforce-lightning-design-system-vf.css')}" /> <apex:includeScript value="https://code.jquery.com/jquery-1.11.3.min.js" /> <body> <div class="slds"> <apex:remoteObjects > <apex:remoteObjectModel name="Sub_Item__c" jsShorthand="SubItems" fields="Id,Name,Product__c,Delivery_Date__c,Sub_Item_Quantity__c,OLI_Id__c,Delivery_Date_Text__c,Opportunity_Product_Mirror__c"> </apex:remoteObjectModel> </apex:remoteObjects> </div> </body> </apex:page>
Creating the Interface
The interface will consist of the following LDS components:
- Grid System – used to center the components within the grid
tags.
- Cards – all buttons, tables, and inputs will be confined with the card
tags.
- Buttons – all buttons needed for actions on the page.
- Data Tables – used to display Sub Item data
To center the contents of the page I will use the Grid System component using the class slds-container–center slds-container–medium. All other component within that div tag will be horizontally centered. Next item will be the card component which is used as the outer container for all components for this interface. All other elements will be residing within this element. Headers and button are the first to be added to the card component. The three buttons included in the markup below serve the following functions:
- Add Item : An onclick event will occur calling the JavaScript function addItem which will add additional row to the table of sub item records.
- Save Item : An onclick event will occur calling the JavaScript function saveItems which will add perform an upset operation.
- Go Back : An onclick event will occur executing JavaScript code that will re-direct the page back to the Opportunity Product detail page.
. . . . . . . . . . . . . . . . . . . . . . . <div class="slds-container--center slds-container--medium"> <div class="slds-card"> <div class="slds-card__header slds-grid"> <div class="slds-media slds-media--center slds-has-flexi-truncate"> <div class="slds-media__figure" > <img src="{!URLFOR($Resource.SIICON)}"/> </div> <div class="slds-media__body"> <h2 class="slds-text-heading--small slds-truncate">Sub Items</h2> </div> </div> <div class="slds-no-flex"> <div class="slds-button-group"> <button class="slds-button slds-button--neutral slds-button--small" onclick="addItem();">Add Item</button> <button class="slds-button slds-button--neutral slds-button--small" onclick="saveItems();">Save Items</button> <button class="slds-button slds-button--neutral slds-button--small" onclick="window.top.location.href ='/' + '{!Opportunity_Product_Mirror__c.Opportunity_Product_Id__c}';">Go Back</button> </div> </div> </div> . . . . . . . . . . . . . . . . . . . . . . .
The remaining portion of the markup will be the body of the card component. The table will consist of headers for the following fields:
- Sub Item Auto Number
- Name of the Product
- Delivery Date of the Sub Item
- The Quantity of the Sub Item
- A column to render Delete button for each sub item.
The table body will be generated by javascript code on page load by querying against the Sub Items object using Visualforce Remote Objects. That is why it is left empty in the markup below.
. . . . . . . . . . . . . . . . . . . . . . . <div class="slds-card__body"> <table class="slds-table slds-table--bordered slds-max-medium-table--stacked-horizontal slds-no-row-hover"> <thead> <tr> <th class="slds-text-heading--label slds-size--1-of-5" scope="col"></th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Sub Item Name</th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Product</th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Delivery Date</th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Quantity</th> </tr> </thead> <tbody id="tableBody"> </tbody> </table> </div> </div> </div>
Populating the Table With Data
As mentioned above I will be running a query on page load. The following javascript code snippet shows I am accomplishing this task.
When the page content has loaded the function getSubItems() is called. The function instantiates a remote object model for the Sub Item object defined at the top of the Visualforce page via the subItems variable. The retrieve function is then called with query filters to pull only sub items related to the current Opportunity Product Mirror record Id and also a callback function to handle any errors along with processing any retrieved records.
Within the callback function the retrieved records are iterated over with each record resulting in a new table row markup and table cells corresponding to a sub item field value. The markup is stored in a string and is then added within the table body tags via jQuery.
A few items to note :
- All input and row elements contains an id attribute that referencing the corresponding sub item record Id. This will be used later on when saving and deleting records via the interface.
- The left most td element contains a button that will upon a click event will call the deleteItem function passing the current sub item record Id.
<script type"text/javascript"> var $j = jQuery.noConflict(); var productName = '{!Opportunity_Product_Mirror__c.Product__r.Name}'; var subItemRecs; var newItemCnt = 0; $j(document).ready(function(){ getSubItems(); }); function getSubItems(){ var subItems = new SObjectModel.SubItem(); var oliId = '{!Opportunity_Product_Mirror__c.Opportunity_Product_Id__c}' subItems.retrieve( { where : { Opportunity_Product_Mirror__c : { eq : '{!Opportunity_Product_Mirror__c.Id}' } }, limit : 100, orderby : [{ Name : "ASC" }] }, function(err, records, event) { if(err){ console.log(err.message); } else{ var rows = ''; subItemRecs = records; for(var i = 0; i < records.length; i++){ console.log('retrieved delivery date: ' + records[i].get("Delivery_Date_Text__c")); var rows = rows + '<tr class="slds-hint-parent" id="SIROW' + records[i].get("Id") + '">' + '<td class="slds-size--1-of-5">' + '<button class="slds-button slds-button--brand slds-button--small" onclick="' + 'deleteItem(' + "'" + records[i].get("Id") + "');" +'">' + 'Delete</button>' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="siname" disabled="true" class="slds-input" type="text" id="' + 'SIID' + records[i].get("Id") + '"' + ' value="' + records[i].get("Name") + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="prodname" disabled="true" class="slds-input" type="text" id="' + 'SIP' + records[i].get("Id") + '"' + ' value="' + records[i].get("Product__c") + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="deliverydate" class="slds-input" type="date" id="' + 'SIDD' + records[i].get("Id") + '"' + 'value="' + records[i].get("Delivery_Date_Text__c") + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="siquantity" class="slds-input" type="text" id="' + 'SIQ' + records[i].get("Id") + '"' + 'value="' + records[i].get("Sub_Item_Quantity__c") + '" />' + '</td>' + '</tr>'; } //console.log(rows); $j("#tableBody").html(rows); } }); }
Once the records are loaded onto table, the page should look like this screen shot.
Adding Sub Items
When the Add Item button is clicked the addItem() function is called to insert a new row into the table. The function starts by selecting all existing markup within the table body element. The new row and empty data cells are added to the existing rows as strings and the new content replaces the existing table body content. Note that in the <tr> element I pass the current value of newItemCnt which is used to track new sub items that are being added as those rows will not have a corresponding sub item record Id. The delete button also passes the newItemCnt value into the deleteItem function as well in order to remove the row from the table. The variable is incremented by 1 each time this function is called.
function addItem(){ var rows = $j("#tableBody").html(); //console.log(rows); rows = rows + '<tr class="slds-hint-parent" id="NEWSI' + newItemCnt + '">' + '<td class="slds-size--1-of-5">' + '<button class="slds-button slds-button--brand slds-button--small" onclick="' + 'deleteItem(' + "'" + 'NEWSI' + newItemCnt + "'" +');' +'">' + 'Delete</button>' + '</td>' + '<td class="slds-size--1-of-5">' + '<input id="SIID" name="siname" disabled="true" class="slds-input" type="text"' + ' value="' + 'TDB' + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input disabled="true" class="slds-input" type="text" value="' + productName +'" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name"deliverydate"' + ' placeholder="Enter date" class="slds-input" type="text" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="siquantity"' + ' placeholder="Enter Quantity" class="slds-input" type="text" />' + '</td>' + '</tr>'; $j("#tableBody").html(rows); newItemCnt++; }
Saving the Items
The Save Items button has an onclick event that will call the saveItems function that will perform an upsert of all records displayed in the table. All rows within the table is selected and each row elements children are selected. The following child elements are retrieved for each row.
- The second cell element’s (td) child which is the input element for the sub item name.
- The Id attribute of the input is retrieved and the split function is used to separate the text “SIID” and the sub item record Id stored within the Id attribute value.
- The fourth cell element’s child which is the input element for the delivery date.
- The value of the date input is retrieved and the string value is used to instantiate the date object.
- The fifth cell element’s child which is the input element for quantity.
- The value of the input is retrieved and assigned to the quantity variable.
A sobject of type sub item is instantiated (via visualforce remote object) and the corresponding values that were retrieved from the inputs are assigned to the fields of the sub item object using the set function. If specific row is a new sub item that does not have a record Id then the object’s Id field will not have the Id field set, but the Opportunity_Product_Mirror__c master-details lookup field will be set instead.
The upsert function is called after all fields have been set with a callback function that will alert with any errors, otherwise getSubItems function will be called to refresh the table and store any newly created record ids via the upsert.
function saveItems(){ $j( "#tableBody tr").each(function(index, rowEl){ var tdElems = $j(rowEl).children(); var nameInput = $j(tdElems[1]).children(); var deliveryDateInput = $j(tdElems[3]).children(); var quantityInput= $j(tdElems[4]).children(); console.log($j(deliveryDateInput[0]).val()); var recIdSplit = $j(nameInput[0]).attr("Id").split("SIID"); var recId = recIdSplit[1]; var dateString = $j(deliveryDateInput[0]).val().split("-"); var deliveryDate = new Date(dateString[0] + "-" + dateString[1] + "-" + dateString[2]); var quantity = $j(quantityInput[0]).val(); var subItem = new SObjectModel.SubItem(); if(recId !== null && recId !== '' && recId !== undefined){ subItem.set('Id', recId); } else{ subItem.set('Opportunity_Product_Mirror__c', '{!Opportunity_Product_Mirror__c.Id}'); } subItem.set('Delivery_Date__c', deliveryDate); subItem.set('Sub_Item_Quantity__c', quantity); console.log(subItem.get("Id")); subItem.upsert(function(err, results, event){ if(err){ console.log(err); alert(err); } }); getSubItems(); }); }
Deleting Items
The deleteItem function is called when the Delete button from each table row is clicked. Each delete button element has an onclick event that calls deleteItem and passes either the sub item record Id of the value or the newItemCnt variable at the time the sub item row was added to the table as an argument.
function deleteItem(delId){ var idx = delId.indexOf('NEWSI'); if(idx !== -1){ console.log("Unsaved Record Delete"); $j("#" + delId).remove(); } else{ console.log("Saved Record Delete"); var subItem = new SObjectModel.SubItem(); subItem.del([delId], function(err, ids){ if(err){ alert(err); } else{ alert("Delete Successful!"); $j("#SIROW" + delId).remove(); } }); } //console.log("delete"); }
If the delId variable contains the string “NEWSI” then only the specific row is removed from the table. For delId values that actually contain a record Id, a new sub item object is instantiated and a the del function is called passing the delId as an argument. If there are errors then an alert will be called, otherwise a successful delete action will result in the corresponding row in the table to be removed.
Conclusion
That complete my quick overview of using LDS and Visualforce remote objects to build a mass insert / update / delete page. Thank you for reading! Please stay tuned for upcoming posts by following the blog or me on twitter. I have included the entire contents of the Visualforce page below for your reference.
<apex:page showHeader="true" sidebar="false" docType="html-5.0" standardController="Opportunity_Product_Mirror__c"> <apex:stylesheet value="{!URLFOR($Resource.SLDS090, 'assets/styles/salesforce-lightning-design-system-vf.css')}" /> <apex:includeScript value="https://code.jquery.com/jquery-1.11.3.min.js" /> <body> <div class="slds"> <apex:remoteObjects > <apex:remoteObjectModel name="Sub_Item__c" jsShorthand="SubItem" fields="Id,Name,Product__c,Delivery_Date__c,Sub_Item_Quantity__c,OLI_Id__c,Delivery_Date_Text__c,Opportunity_Product_Mirror__c"> </apex:remoteObjectModel> </apex:remoteObjects> <div class="slds-container--center slds-container--medium"> <div class="slds-card"> <div class="slds-card__header slds-grid"> <div class="slds-media slds-media--center slds-has-flexi-truncate"> <div class="slds-media__figure" > <img src="{!URLFOR($Resource.SIICON)}"/> </div> <div class="slds-media__body"> <h2 class="slds-text-heading--small slds-truncate">Sub Items</h2> </div> </div> <div class="slds-no-flex"> <div class="slds-button-group"> <button class="slds-button slds-button--neutral slds-button--small" onclick="addItem();">Add Item</button> <button class="slds-button slds-button--neutral slds-button--small" onclick="saveItems();">Save Items</button> <button class="slds-button slds-button--neutral slds-button--small" onclick="window.top.location.href ='/' + '{!Opportunity_Product_Mirror__c.Opportunity_Product_Id__c}';">Go Back</button> </div> </div> </div> <div class="slds-card__body"> <table class="slds-table slds-table--bordered slds-max-medium-table--stacked-horizontal slds-no-row-hover"> <thead> <tr> <th class="slds-text-heading--label slds-size--1-of-5" scope="col"></th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Sub Item Name</th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Product</th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Delivery Date</th> <th class="slds-text-heading--label slds-size--1-of-5" scope="col">Quantity</th> </tr> </thead> <tbody id="tableBody"> </tbody> </table> </div> </div> </div> <script type="text/javascript"> var $j = jQuery.noConflict(); var productName = '{!Opportunity_Product_Mirror__c.Product__r.Name}'; var subItemRecs; var newItemCnt = 0; $j(document).ready(function(){ getSubItems(); }); function getSubItems(){ var subItems = new SObjectModel.SubItem(); var oliId = '{!Opportunity_Product_Mirror__c.Opportunity_Product_Id__c}' subItems.retrieve( { where : { Opportunity_Product_Mirror__c : { eq : '{!Opportunity_Product_Mirror__c.Id}' } }, limit : 100, orderby : [{ Name : "ASC" }] }, function(err, records, event) { if(err){ console.log(err.message); } else{ var rows = ''; subItemRecs = records; for(var i = 0; i < records.length; i++){ console.log('retrieved delivery date: ' + records[i].get("Delivery_Date_Text__c")); var rows = rows + '<tr class="slds-hint-parent" id="SIROW' + records[i].get("Id") + '">' + '<td class="slds-size--1-of-5">' + '<button class="slds-button slds-button--brand slds-button--small" onclick="' + 'deleteItem(' + "'" + records[i].get("Id") + "');" +'">' + 'Delete</button>' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="siname" disabled="true" class="slds-input" type="text" id="' + 'SIID' + records[i].get("Id") + '"' + ' value="' + records[i].get("Name") + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="prodname" disabled="true" class="slds-input" type="text" id="' + 'SIP' + records[i].get("Id") + '"' + ' value="' + records[i].get("Product__c") + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="deliverydate" class="slds-input" type="date" id="' + 'SIDD' + records[i].get("Id") + '"' + 'value="' + records[i].get("Delivery_Date_Text__c") + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="siquantity" class="slds-input" type="text" id="' + 'SIQ' + records[i].get("Id") + '"' + 'value="' + records[i].get("Sub_Item_Quantity__c") + '" />' + '</td>' + '</tr>'; } //console.log(rows); $j("#tableBody").html(rows); } }); } function addItem(){ var rows = $j("#tableBody").html(); //console.log(rows); rows = rows + '<tr class="slds-hint-parent" id="NEWSI' + newItemCnt + '">' + '<td class="slds-size--1-of-5">' + '<button class="slds-button slds-button--brand slds-button--small" onclick="' + 'deleteItem(' + "'" + 'NEWSI' + newItemCnt + "'" +');' +'">' + 'Delete</button>' + '</td>' + '<td class="slds-size--1-of-5">' + '<input id="SIID" name="siname" disabled="true" class="slds-input" type="text"' + ' value="' + 'TDB' + '" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input disabled="true" class="slds-input" type="text" value="' + productName +'" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name"deliverydate"' + ' placeholder="Enter date" class="slds-input" type="text" />' + '</td>' + '<td class="slds-size--1-of-5">' + '<input name="siquantity"' + ' placeholder="Enter Quantity" class="slds-input" type="text" />' + '</td>' + '</tr>'; $j("#tableBody").html(rows); newItemCnt++; } function deleteItem(delId){ var idx = delId.indexOf('NEWSI'); if(idx !== -1){ console.log("Unsaved Record Delete"); $j("#" + delId).remove(); } else{ console.log("Saved Record Delete"); var subItem = new SObjectModel.SubItem(); subItem.del([delId], function(err, ids){ if(err){ alert(err); } else{ alert("Delete Successful!"); $j("#SIROW" + delId).remove(); } }); } //console.log("delete"); } function saveItems(){ $j( "#tableBody tr").each(function(index, rowEl){ var tdElems = $j(rowEl).children(); var nameInput = $j(tdElems[1]).children(); var deliveryDateInput = $j(tdElems[3]).children(); var quantityInput= $j(tdElems[4]).children(); console.log($j(deliveryDateInput[0]).val()); var recIdSplit = $j(nameInput[0]).attr("Id").split("SIID"); var recId = recIdSplit[1]; var dateString = $j(deliveryDateInput[0]).val().split("-"); var deliveryDate = new Date(dateString[0] + "-" + dateString[1] + "-" + dateString[2]); var quantity = $j(quantityInput[0]).val(); var subItem = new SObjectModel.SubItem(); if(recId !== null && recId !== '' && recId !== undefined){ subItem.set('Id', recId); } else{ subItem.set('Opportunity_Product_Mirror__c', '{!Opportunity_Product_Mirror__c.Id}'); } subItem.set('Delivery_Date__c', deliveryDate); subItem.set('Sub_Item_Quantity__c', quantity); console.log(subItem.get("Id")); subItem.upsert(function(err, results, event){ if(err){ console.log(err); alert(err); } }); getSubItems(); }); } </script> </div> </body> </apex:page>
2 thoughts on “Custom Mass Insert / Update Interface via Lightning Design”