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.
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!