Showing posts with label Modern JavaScript. Show all posts
Showing posts with label Modern JavaScript. Show all posts

Sunday, May 10, 2020

Developing Product Catalog on Salesforce Lightning Web Components using TreeView Drag and Drop functionality

Motivation behind this


I have received a requirement on building a product catalog which looks like treeview with defined hierarchies and should possess greater user experiences. Since I am currently working and exploring on Lightning Web Components so thought of building it for my own.

I have also tried to implement drag and drop functionality which is not available on this type of use case upon googling which drives me to come up with quick prototype.

I have written a blog post on Drag and Drop functionality using Salesforce Lightning Web Components leveraging pub-sub event propagation which sets me the initial ground on drag and drop functionality.

During this development, I have tried for use modern JavaScript functions like map, searching elements with find function with arrow function, removing items through filter function and splice function, encountered few challenges when using lightning-tree-grid and gained knowledge on solving those which I want to share right here.

Specially, adding items into the nested treeview node along with deleting them makes me crazy during this poc.

Let's get started.

Use Case


Business has a requirement to build product catalog where user can drag and drop the products on the treeview and quickly build a defined hierarchy. User also wants greater user experience.

This requirement can also be leveraged for building training catalog and suitable for CPQ functionality also.

Before dragging the products destination nodes (one or multiple) can be selected.

User will also have a provision to delete a node and its children.

Possible End Result


After building the use case, it will perform the functionality as following video:


Solution Approach


This overall functionality can be divided into 3 parts.
  • Populating list of Products from database and display them using lightning-checkbox-group
  • Registering events and preparing drag and drop functionality where lightning-pill has been used for dragging.
  • Finally, dropping items on the treeview and prepare hierarchy.
buildProductCatalogue component:

buildProductCatalogue.html will prepare a screen like this:


Code is as follows:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<template>
    <lightning-card title="Build a Product Catalogue using Lightning Web Components through Drag and Drop">
        <div class="c-container">
            <lightning-layout>                
                <lightning-layout-item padding="around-small"> 
                    <!-- this section is for combo box group -->
                    <div class="slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click slds-has-focus" 
                        role="none">
                        <div class="slds-text-heading_small">Choose Product(s)</div> 
                        <lightning-checkbox-group name="Checkbox Group"
                                label=""
                                options={listItems}
                                value={listValues}
                                onchange={handleCheckboxChange}>
                        </lightning-checkbox-group>   
                    </div>    
                </lightning-layout-item>
                <lightning-layout-item padding="around-small">
                    <!-- this section is selected items to display as pills -->
                    <div class="slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click slds-is-open" 
                            aria-expanded="true" aria-haspopup="listbox" role="none">
                        <div class="slds-text-heading_small" if:true={isProductsSelected}>Selected products to drag</div>  
                        <div class="slds-form-element__control slds-input-has-icon slds-input-has-icon slds-input-has-icon_left-right" role="none"
                                style = "width:300px;" 
                                draggable="true" 
                                ondragstart={handleDragStart}>
                            <template for:each={globalSelectedItems} for:item="selectedItem">
                                <span key={selectedItem.value}>
                                    <lightning-pill label={selectedItem.label} name={selectedItem.value} 
                                        data-item={selectedItem.value}
                                        onremove={handleRemoveComboItems}>
                                        <lightning-icon icon-name={iconName} variant="circle" 
                                            alternative-text={selectedItem.label}></lightning-icon>
                                    </lightning-pill>
                                </span>
                            </template>                     
                        </div>
                    </div> 
                    </lightning-layout-item>
                <lightning-layout-item padding="around-small"> 
                    <!-- define dropzone in the div when item will be dropped-->
                    <div class="slds-text-heading_small">Drop Product(s) on Selected Items</div>                       
                    <div class="dropcls" dropzone="link" style = "width:550px;">
                        <div class="slds-p-around_medium lgc-bg">
                            <lightning-tree-grid
                                columns={columns}
                                data={treeList}
                                key-field="name"
                                onrowselection = {handleTreeNodeSelect}
                                onrowaction = {handleRowAction}
                                ondrop = {handleDrop}>
                            </lightning-tree-grid>
                        </div>                 
                    </div>                      
                </lightning-layout-item>  
            </lightning-layout> 
        </div>
    </lightning-card>
</template>

Few notable points on the above HTML:
  • lightning-pill div has been wrapped within a div which has made draggable="true" and ondragstart event kicks start during drag.
  • Unless no products get selected, "Selected products to drag" div will not be displayed, for this if:true statement has been used. The same can also be used for any other lightning base components as follows:
<lightning-formatted-text 
	value="Selected products to drag" 
	if:true={isProductsSelected}
	data-item="selectedProduct"> 
</lightning-formatted-text>

We don't need to write template if:true condition.

  • lightning-tree-grid has been wrapped with a div with dropzone="link" so items can be dropped on this and ondrop event will fire.
buildProductCatalogue.js

Some the key points have been highlighted below:
  • dragover event has been registered in the constructor which helps to call handleDragOver function
this.template.addEventListener('dragover', this.handleDragOver.bind(this));
  • dataTransfer object is used for defining dropEffect and carrying the data using setData method as follows where combo box selected items have been added as 'Text'
handleDragStart(event) {
        event.dataTransfer.dropEffect = 'move';
        event.dataTransfer.setData('text', JSON.stringify(this.selectedItems));
    }

  • handleDrop function retrieves the data from dataTransfer object and parse and add those under selected tree nodes.
  • btw, to display Type column with icons those needs to be defined as cellAttributes and treeIcon has been dynamically populated.


{label: 'Type', fieldName: 'type', type: 'text',fixedWidth:150, initialWidth:150,
        cellAttributes: { iconName: { fieldName: 'treeIcon'} }
    }


  • For sake of simplicity, 3 product catalogs have been defined and all the products will be defined as hierarchy of Product Line, Product Group, Product and Item. Those they can be nth level of hierarchy.
  • During addition and deletion of node item recursive logic has been introduced. refer appendNewItems and deleteTreeNode functions.
  • Mind that the child nodes can only be added within _children where as for treeview it will be under items array.
Entire js file as below:



  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
/*
* Author: Santanu Boral
*/
import { LightningElement, track, wire } from 'lwc';
import getAllProducts from '@salesforce/apex/ProductController.getAllProducts';

const actions = [
    { label: 'Delete', name: 'delete' }
];

//define treegrid columns
const columns = [
    {label: 'Catalogue Hierarchy', fieldName: 'label', type: 'text'},
    {label: 'Type', fieldName: 'type', type: 'text',fixedWidth:150, initialWidth:150,
        cellAttributes: { iconName: { fieldName: 'treeIcon'} }
    },
    {type: 'action', typeAttributes: { rowActions: actions }}, 
];
//define initial Product Catalogue rows
const items = [{
        label: "Product Catalogue 1", name:"Product Catalogue 1",
        type: "Catalogue",
        treeIcon: 'standard:catalog',
        _children: [],
        expanded: false,
    }, {
        label: "Product Catalogue 2", name:"Product Catalogue 2",
        type: "Catalogue",
        treeIcon: 'standard:catalog',
        _children: [],
        expanded: false,
    }, {
        label: "Product Catalogue 3", name:"Product Catalogue 3",
        type: "Catalogue",
        treeIcon: 'standard:catalog',
        _children: [],
        expanded: false,
}];

export default class BuildProductCatalogue extends LightningElement {
    iconName = 'standard:outcome';
    @track treeList = items;
    @track columns = columns;    
    @track listItems = []; //combobox items
    @track listValues = []; //combobox values
    @track selectedItems = []; //to hold combo box selected items
    @track globalSelectedItems = []; //for displaying pills
    @track selectedTreeNodes = []; // for holding selected tree nodes
    isProductsSelected = false;

    constructor() {
        super();
        //register dragover event to the template
        this.template.addEventListener('dragover', this.handleDragOver.bind(this));
    }
    
    //retrieve all the products from database
    @wire(getAllProducts)
    wiredAllProducts({ error, data }) {
        if (data) {
            data.map(element=>{
                this.listItems = [...this.listItems,{value:element.Id, 
                                                    label:element.Name}];
            });
            this.error = undefined;            
        } else if (error) {
            this.error = error;
            this.treeList = undefined;
        }
    }    

    //This method is called during checkbox value change
    handleCheckboxChange(event){
        let selectItemTemp = event.detail.value;
        this.selectedItems = []; //it will hold only newly selected checkbox items.        
        
        /* find the value in items array which has been prepared during database call
           and push the key/value inside selectedItems array           
        */
        selectItemTemp.map(p=>{            
            let arr = this.listItems.find(element => element.value == p);
            if(arr != undefined){
                this.selectedItems.push(arr);
                this.listValues.push(arr.value);
            }  
        });
        this.globalSelectedItems = this.selectedItems;        
        this.isProductsSelected = this.selectedItems.length>0;         
    }

    //this will removes combo box values
    handleRemoveComboItems(event){        
        const removeItem = event.target.dataset.item; //"0032v00002x7UEHAA2" 
        
        this.listValues = this.listValues.filter(item => item != removeItem);
        
        //this will prepare globalSelectedItems array excluding the item to be removed.
        this.globalSelectedItems = this.globalSelectedItems.filter(item => item.value  != removeItem);
        this.selectedItems = this.globalSelectedItems;
        this.isProductsSelected = this.globalSelectedItems.length>0; 
    }

    //when drag is start this method fires
    handleDragStart(event) {
        event.dataTransfer.dropEffect = 'move';
        event.dataTransfer.setData('text', JSON.stringify(this.selectedItems));
    }

    handleDragOver(event){
        event.dataTransfer.dropEffect = 'move';
        event.preventDefault();
    } 

    //when item is dropped this event fires
    handleDrop(event){        
        event.stopPropagation();        
        event.preventDefault();
        event.dataTransfer.dropEffect = 'copy';        
        let droppedItems = event.dataTransfer.getData('text');
        let droppedProductList =  Array.from(JSON.parse(droppedItems));
        
        if(droppedProductList !=null && droppedProductList!= undefined){
            
            this.selectedTreeNodes.map(nodeItem=>{
                //prepare type
                let droppedItemType = nodeItem.type ===  'Catalogue'? 'Product Line':
                                      nodeItem.type === 'Product Line'? 'Product Group':
                                      nodeItem.type === 'Product Group'? 'Product': 'Item';                                      
                //assign icons based on type
                let droppedIcon = droppedItemType == 'Product Line'? 'standard:assignment': 
                                    droppedItemType == 'Product Group'? 'standard:flow':
                                    droppedItemType == 'Product'? 'standard:reward': 'standard:task2';      

                let newChildren = [];
                //create array for children and append new items under treenode                
                droppedProductList.map(item=>{
                    const newItem = {
                        label: item.label,
                        name: nodeItem.name + '-' + item.label,
                        type: droppedItemType,
                        treeIcon: droppedIcon,
                        expanded: true,
                        _children: []
                    }; 
                    newChildren.push(newItem);
                });
                this.treeList = appendNewItems(nodeItem.name, this.treeList, newChildren);
            });
        } 
        //remove product list selection.
        this.listValues = []; 
        this.globalSelectedItems = [];
        this.isProductsSelected = false;
    }

    //this selects treegrid node
    handleTreeNodeSelect(event){
        this.selectedTreeNodes = event.detail.selectedRows;
    }
    
    //this deletes the items from treegrid
    handleRowAction(event) {
        const action = event.detail.action;
        const row = event.detail.row;
        switch (action.name) {
            case 'delete':
                const rows = Array.from(this.treeList);
                deleteTreeNode(row.name, rows);
                this.treeList = rows;
                break;
        }
    }
}

//this function traverses nested tree and push the children
function appendNewItems (rowName, data, children) {
    return data.map(row=>{
        if(row.name === rowName){
            //if children exists then loop through children to push
            if (row.hasOwnProperty('_children') && Array.isArray(row._children) 
                    && row._children.length > 0) {
                children.map(newItem=>{
                    row._children.push(newItem);
                });                
            } else {
                row._children = children;
            }
            return row;        
        } else {
            appendNewItems(rowName, row._children, children);
        }
        return row;
    });
}

//this function traverses nested tree to delete the item
function deleteTreeNode (rowName,data) {    
    return data.map(row=> {  
        const rows = data;
        if(row.name === rowName){
            row._children = [];            
            const rowIndex = rows.indexOf(row);  
            rows.splice(rowIndex, 1);
            return rows;
        } else {
            deleteTreeNode(rowName, row._children);
        }        
    });
}

ProductController.cls

This class is fetching sample product data from database.



1
2
3
4
5
6
public with sharing class ProductController {
    @AuraEnabled (cacheable=true)
    public static List<Custom_Product__c> getAllProducts(){
        return [SELECT Id, Name FROM Custom_Product__c];
    }
}

Overall, it is a nice experience to build this use case with Lightning Web Components. Lots of learning.

Thanks for reading!


References



Further Reading


Sunday, July 28, 2019

Display Google Map using Salesforce Lightning Web Components leveraging modern JavaScript

Motivation behind this


Currently I am exploring Lightning Web Components (LWC) and trying to display Google map using LWC leveraging modern JavaScript (ES6 Standard). Since modern JavaScript has new functions like Spread function, Arrow function which are well blended into LWC JavaScript controller and hence, drives me to come out with this proof-of-concept.

Use Case


Business has a requirement to display their various office locations in the Google Map which will get retrieved from Salesforce Account Object.

Developer wants to build this requirement using Lightning Web Components.


Expected Outcome





In the above picture I am displaying our company office locations (Bangaluru and Noida) in Google Map. I have taken only 2 locations for simplicity.

Solution Approach


Salesforce provides lightning-map component which displays one or more locations. It inherits the styling from map of Lighting Design System. 

So, it is better to use it.

Syntax for using lightning-map component


<template>
    <lightning-map
 map-markers={mapMarkers}
 markers-title="Our Company Location">
  </lightning-map>
</template>

A marker contains
  • Location Information: A coordinate pair of latitude and longitude, or an address composed of address elements.
  • Descriptive Information: Optional title, description, and an icon. These are relevant to the marker but not specifically related to location.
For more information, refer lightning-map documentation

Little bit homework for preparing data

Since data to be displayed dynamically from database, so created 2 Account records with a name starts with Tavant providing complete BillingAddress information. I have not given Latitude and Longitude as Location which can also be used alternatively.

Let's first construct the Apex class.

DisplayMapController.cls

getOfficeLocations method accepts accountNameInitial  as input parameter and returns List of Accounts based on SOQL query. We are not doing any data transformation which can be recognized in map display, rather let it be handled by LWC js controller will be mentioned below.

Note: 
@AuraEnabled (cacheable=true) used as it will be called from js controller and @wire is calling the method
Secondly, use of bind (:str) variables to get rid of SOQL injection.



public with sharing class DisplayMapController {
    
    @AuraEnabled (cacheable=true)
    public static List<Account> getOfficeLocations(String accountNameInitial){
        String str = accountNameInitial + '%';
        return [SELECT Id, Name, BillingStreet, BillingCity, BillingState, BillingPostalCode, BillingCountry 
                FROM Account 
                WHERE Name LIKE :str];       
    }
}


Let's develop Lightning Web Components

Create a VSCode project with a menu selection SFDX: Create Lightning Web Component and provide a name displayLWCMap, which creates 3 files html (UI) , js (Controller) and xml (Configuration) at your chosen directory.

displayLWCMap.html

Inside the template lightning-map component has been used and also zoom-level has been used as additional attribute apart from map-markers and markers-title.

<template>
    <div class="slds-m-around_medium">
        <h1 class="slds-text-heading_small">Tavant Technologies</h1>
        <p class="slds-text-body_regular">Office locations</p>
    </div>
    <lightning-map 
        map-markers={mapMarkers}
        markers-title={markersTitle}
        zoom-level={zoomLevel}>
    </lightning-map>   
</template>

displayLWCMap.js

Good thing of LWC is handling all attributes as js controller level which is opposed to Aura and this class is most important for implementing this functionality.

Follow these steps:
  • import LightningElement, api, wire, track which will be used in this class.
  • import getOfficeLocations as we will be using getOfficeLocations method of DisplayMapController apex class.
  • At the class level, 
    • accountNameParam which is public reactive property decorated with @api
    • error, mapMarkers, markersTitle and zoomLevel are private reactive properties
  • @wire to function i.e getOfficeLocations and passing parameter '$accountNameParam' for accountName (here, parameter has been hard coded, though it can passed from html)
    • This is function, retrieve the data and prepare mapMarkers array with location, icon and title which should be recognized and used in the lightning-map.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { LightningElement , wire, track, api} from 'lwc';

import getOfficeLocations from '@salesforce/apex/DisplayMapController.getOfficeLocations';

export default class DisplayLWCMap extends LightningElement {

    @api accountNameParam; 
    accountNameParam = 'Tavant';
    @track error;   //this holds errors

    @track mapMarkers = [];
    @track markersTitle = 'Tavant Technologies';
    @track zoomLevel = 4;
    /* Load address information based on accountNameParam from Controller */
    @wire(getOfficeLocations, { accountNameInitial: '$accountNameParam'})
    wiredOfficeLocations({ error, data }) {
        if (data) {            
            data.forEach(dataItem => {
                this.mapMarkers = [...this.mapMarkers ,
                    {
                        location: {
                            City: dataItem.BillingCity,
                            Country: dataItem.BillingCountry,
                        },
        
                        icon: 'custom:custom26',
                        title: dataItem.Name,
                    }                                    
                ];
              });            
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.contacts = undefined;
        }
    }

}

displayLWCMap.js-meta.xml


This is configuration file which states where this component will be available.
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata" fqn="displayLWCMap">
    <apiVersion>45.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
    <target>lightning__AppPage</target>
    <target>lightning__RecordPage</target>
    <target>lightning__HomePage</target>
  </targets>
</LightningComponentBundle>


Leveraging Modern JavaScript


Arrow Function

You can observe that following statement has been used which doesn't have function() and return statement.

data.forEach(dataItem => {
  //method body without return statement
}

For example, earlier we use write this way to using function and return statement.

let result = function (a,b) {
  return a+b; 
}
console.log(result(1,2));

Now, with Arrow function, it can be written as:

let result = (a,b) => return a+b; 

console.log(result(1,2))

Also, Arrow function resolves some confusion when dealing with this keyword, when nested functions are involved. Functions have a special variable called this, often referred to as the “dynamic this,” which refers to the object used to invoke the function.

For more information, Refer Understand JavaScript Functions

Spread Operator

Spread operator helps to expand the array which we have used for creating mapMarkers array. Here is an example:


let arr = ['a','b']; 
let arr2 = [...arr,'c','d']; 
  
console.log(arr2); // [ 'a', 'b', 'c', 'd' ] 

We can see, arr values has been appended first and then c and d have been added to the same array.

Similarly, we can append array values at end the array too.


let arr = ['a','b']; 
let arr2 = ['c','d',...arr]; 
  
console.log(arr2); // [ 'c', 'd', 'a', 'b']