Sunday, September 29, 2019

Drag and Drop functionality using Salesforce Lightning Web Components leveraging pub-sub event propagation

Motivation behind this


I am exploring Lightning Web Components and thought of preparing an use case on drag and drop functionality, but want to leverage native HTML drag-n-drop without using any 3rd party libraries. Also wanted that, this functionality will be build up using two unrelated components and where this drag-n-drop could be challenging.

This motivates to me to build a proof-of-concept and share the challenges and knowledge to community members.

Let's get started.

Use Case


Business has a requirement to see a Company's location in the Google Map when Company info is dragged and dropped to map.

Developer wants to build using Lightning Web Components without 3rd party libraries and want to leverage native HTML functionalities.


Possible End Results


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


Solution Approach


Let's first separate the functionalities in the two components as shown in the picture.

displayAccount component will have functionality shown on left side and showAccountLocation component will have functionality as right side.

Flow Diagram

Create a flow diagram like this way to understand the functionalities, flow of control and finalize design approach.



Flow diagram shows the preparation and action for drag-n-drop functionality component wise.

Before going through this below section, it will be better to go through my post on Salesforce Lightning Web Components Cheat Sheet to have overall idea for all types of properties, methods, event propagation etc.

Pre-requisite:

Add pubsub component from repo and add it to project folder. This will be used for event communication using publish-subscribe pattern.

displayAccount component:

displayAccount.html will prepare a screen like this:




Code is as follows:

<template> 
    <lightning-card title="Lightning Web Components: Drag and Drag functionality 
                                with pub-sub pattern event communication" 
                icon-name="custom:custom30">
    <div class="c-container">  
        <lightning-layout>
            <lightning-layout-item padding="around-small">   
                <template if:true={accounts.data}>                
                    <template for:each={accounts.data} for:item="account">
                        <!-- use draggable="true" to make div as draggable-->
                        <div class="column" draggable="true" key={account.Id}
                            data-item={account.Id} ondragstart={handleDragStart}>
                            <div class="slds-text-heading_small">{account.Name}</div>
                        </div>
                    </template>
                </template>
            </lightning-layout-item>
        </lightning-layout>
    </div>
    </lightning-card>
</template>

To make <div/> as draggable, draggable="true" and ondragstart={handleDragStart} event handler have been used.

displayAccount.js

This is most important for implementing this functionality and if we follow the flow diagram it is easy to understand which methods are performing which responsibilities. Some important points to be highlighted:


  • import LightningElement, wire which will be used in this class.
  • import CurrentPageReference and getAccountLocations 
  • import fireEvent from c/pubsub 
  • @wire to function i.e CurrentPageReference and getAccountLocations (to retrieve account from database through Apex method)
  • register dragover event pragmatically and attached it to template.

this.template.addEventListener('dragover', this.handleDragOver.bind(this));

  • handleDragStart method, retrieve AccountId from the div, this is important line.

accountId = event.target.dataset.item;

  • After that, retrieve Account record and fire event to the subscribers

fireEvent(this.pageRef, 'selectedAccountDrop', selectedAccount);

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
/* eslint-disable no-console */
import { LightningElement, wire} from 'lwc';
import { CurrentPageReference } from 'lightning/navigation';
import { fireEvent } from 'c/pubsub';
import getAccountLocations from '@salesforce/apex/AccountHelper.getAccountLocations';


let i=0;
let accountId; //store id from the div
let selectedAccount; //store selected account

export default class DisplayAccount extends LightningElement {
    
    //get page reference
    @wire(CurrentPageReference) pageRef;
    
    constructor() {
        super();
        //register dragover event to the template
        this.template.addEventListener('dragover', this.handleDragOver.bind(this));
    }
    
    //retrieve account records from database via Apex class
    @wire(getAccountLocations) accounts;

    //when drag is start this method fires
    handleDragStart(event) {
        event.dataTransfer.dropEffect = 'move';
        
        //retrieve AccountId from div
        accountId = event.target.dataset.item;
        //console.log('event.target.dataset.item=' + event.target.dataset.item);

        //loop the array, match the AccountId and retrieve the account record
        for(i=0; i<this.accounts.data.length; i++) {
            if(accountId!==null && accountId === this.accounts.data[i].Id){
                selectedAccount = this.accounts.data[i];               
            }                                                         
        } 

        //fire an event to the subscribers
        fireEvent(this.pageRef, 'selectedAccountDrop', selectedAccount);
    }

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

AccountHelper.cls

getAccountLocations method it fetches Account data based on SOQL query. I want to show specific records for demo, that's why filter condition has been added.



public with sharing class AccountHelper {
    @AuraEnabled (cacheable=true)
    public static List<Account> getAccountLocations(){
        return[SELECT Id, Name, BillingStreet, BillingCity, 
    BillingState, BillingPostalCode, BillingCountry 
                FROM Account
                WHERE isDisplay__c = true];
    }
}

Let's move to other component.

showAccountLocation component

showAccountLocation.html will end up like this:



Code is as follows:



<template>
    <lightning-card title="Lightning Map component will show dropped Company Location" 
        icon-name="custom:custom30">
        <div class="c-container">  
            <lightning-layout>
                <lightning-layout-item padding="around-small"> 
                    <!-- define dropzone in the div when item will be dropped-->                       
                    <div class="dropcls" dropzone="link">
                        <div class="slds-text-heading_small">{DroppedAccountName}</div>                         
                        <lightning-map
                            map-markers={mapMarkers}
                            markers-title={DroppedAccountName}
                            zoom-level ={zoomLevel}
                            center ={center}>
                        </lightning-map>
                        <p><lightning-formatted-text 
                            value="|--Drop the dragged account here --|" > 
                        </lightning-formatted-text></p>                         
                    </div>                        
                </lightning-layout-item>    
            </lightning-layout>
        </div>
    </lightning-card>
</template>

Important things to note:
  • Need to specify dropzone="link" in the div as attribute (pure HTML attribute)
  • Use lightning-map to show Account location in Google Map

showAccountLocation.js

This is most important for implementing this functionality and if we follow the flow diagram it is easy to understand which methods are performing which responsibilities. Some important points to be highlighted:


  • import registerListener, unregisterAllListeners from c/pubsub 
  • registering event listeners for dragover and drop events only in the constructor.

this.template.addEventListener('dragover', this.handleDragOver.bind(this));
this.template.addEventListener('drop', this.handleDrop.bind(this)); 

  • connectedCallback and disconnectedCallback methods for registering and unregistering listeners.
  • handleSelectedAccountDrop method to capture parameters passed during listening an event raised from displayAccount component.
  • handleDrop method prepares the map retrieving values from AccountInfo
  • Be sure to use event.preventDefault() wherever necessary.

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
/* eslint-disable no-console */
import { LightningElement, wire, track } from 'lwc';
import { CurrentPageReference } from 'lightning/navigation';
import { registerListener, unregisterAllListeners } from 'c/pubsub';

let acct;
export default class ShowAccountLocation extends LightningElement {
   
    @track mapMarkers = []; //holds account location related attributes
    @track markersTitle = ''; //title of markers used in lightning map.
    @track zoomLevel = 4;   //initialize zoom level
    @track center; //location will be displayed in the center of the map

    @wire(CurrentPageReference) pageRef;
    accountInfo;
    @track DroppedAccountName = '';
    
    constructor() {
        super();
        //these are two must have events to be listended
        this.template.addEventListener('dragover', this.handleDragOver.bind(this));
        this.template.addEventListener('drop', this.handleDrop.bind(this));        
      }

    connectedCallback() {
        // subscribe to selectedAccountDrop event
        registerListener('selectedAccountDrop', this.handleSelectedAccountDrop, this);
    }

    disconnectedCallback() {
        // unsubscribe from selectedAccountDrop event
        unregisterAllListeners(this);
    }

    //method is called due to listening selectedAccountDrop event
    handleSelectedAccountDrop(accountInfo) {
        this.accountInfo = accountInfo;        
    }

    //when item is dropped this event fires
    handleDrop(event){
        console.log('inside handle drop');
        if(event.stopPropagation){
            event.stopPropagation();
        }
        event.preventDefault();
        
        //assigns account records
        acct = this.accountInfo;
        this.DroppedAccountName = acct.Name;
        
        //prepares information for the lightning map attribute values.
        this.markersTitle = acct.Name;        
        this.mapMarkers = [
            {
                location: {
                    Street: acct.BillingStreet,
                    City: acct.BillingCity,
                    State: acct.BillingState,
                    Country: acct.BillingCountry,
                },
                icon: 'custom:custom26',
                title: acct.Name,
            }                                    
        ];
        this.center = {
            location: {
                City: acct.BillingCity,
            },
        };
        this.zoomLevel = 6;
    }    

    //when item is dragged on the component this event fires
    handleDragOver(event){
        event.dataTransfer.dropEffect = 'move';
        event.preventDefault();       
    }    
}

meta files should include where these components are available.



<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata" fqn="displayAccount">
    <apiVersion>46.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
    </targets>
</LightningComponentBundle>

I have used .css file googling on the net as I am not good in styling.

Create a Lightning App Builder page with two regions and place those components and run the application, it will display the page like this.







Further Reading


Wednesday, September 25, 2019

Pagination using Salesforce Lightning Web Components with array slicing

Motivation behind this


Currently I am exploring Lightning Web Components (LWC) and trying to build pagination functionality using LWC. I was searching on google on the same, but didn't find this functionality on LWC, though there are tons of materials available on the same either using Lightning Aura Components or by Visualforce StandardSetController.

I have seen couple of solutions where developers either used offset to fetch page by page data every time directly from controller's SOQL query or taken entire dataset on the client side controller but every time looping through the array in the for loop to get paginated data.

Both of the solutions are not favorable to me, which motivates me to build a proof-of-concept and sharing a knowledge to Salesforce community members.

I have gone through Create and Dispatch Events documentation, there is some light on pagination but that topic is used to understand on how events are generated and dispatched.

Let's get started.

Use Case


Business has a requirement to view the tabular data retrieving from the database in a paginated way. 

Developer wants to build the same using Lightning Web Components.


Expected Behavior




In the above demo, I am trying to show Account data is displaying page by page.

Before going to solution approach it is recommended to read Salesforce Lightning Web Components Cheat Sheet to have an understanding on different type of variables, dispatching events and calling apex classes' method from js controller.



Solution Approach


There are two components have been used.

1. Paginator component - which contains previous and next button as specified in Create and Dispatch Events documentation. For the sake of simplicity, take all the code as specified there.

2. displayPaginatedRecords component - which contains lightning datatable and Paginator component to implement entire functionality.

Let's first discuss about displayPaginatedRecords component.

displayPaginatedRecords.html

This html contains datatable and Paginator component using c-paginator which is listening events using onprevious and onnext event listeners.



<template>
    <lightning-card title="List of Accounts" icon-name="custom:custom9">
        <div class="slds-m-around_medium">
            <p class="slds-p-horizontal_medium">Display records in paginated way </p>
            <br></br>
            <div style="height: 180px;">
                <lightning-datatable 
                    key-field="id"
                    data={data}
                    columns={columns}>
                </lightning-datatable>
            </div>
        </div>
        <div class="slds-m-around_medium">
            <p class="slds-m-vertical_medium content">
                     Displaying {startingRecord} to {endingRecord} of {totalRecountCount} records.
                     Page {page} of {totalPage}. </p>
            <c-paginator onprevious={previousHandler} onnext={nextHandler}></c-paginator>
        </div>
    </lightning-card>
</template>

PaginationController.cls

This class has retrieveAccounts method which retrieves the data from Account object. For simplicity, WHERE clause and filter criteria have not given.


public with sharing class PaginationController {
    @AuraEnabled (cacheable=true)
    public static List<Account> retrieveAccounts(){
        return [SELECT Id, Name, Type, BillingCountry
                FROM Account
                LIMIT 1000];
    }
}

displayPaginatedRecords.js

Let's talk main important points about this js controller.


  • All the respective @track variables have been declared which have been used in the page, like page, startingRecord, endingRecord, pageSize, totalRecountCount, totalPage, data, columns. All are self-explanatory but written their usage as comments for each.
  • default pageSize = 5, so every page will display 5 records.
  • Datatable columns have been defined with label and fieldName attributes.
  • @wire to function i.e retrieveAccounts which performs following:

  1. retrieves the data from Apex controller and assigns all data in items array
  2. calculates totalRecountCount from data.length and calculates totalPage to be displayed.
  3. Now, main part is: use of Array.slice() which returns selected elements from the array

this.data = this.items.slice(this.startingRecord, this.endingRecord);

The above principle has been applied in displayRecordPerPage method and everything is written as comments for better understanding.

For example, on 2nd page, label will shown as => "Displaying 6 to 10 of 23 records. Page 2 of 5"
        page = 2; pageSize = 5; startingRecord = 5, endingRecord = 10
        so, slice(5,10) will give 5th to 9th records.
        
  • previousHandler and nextHandler methods changes the page and calls displayRecordPerPage method
If we remove the comments from the method bodies then code will be compact. I have mentioned them for easy understanding.



/* eslint-disable no-console */
import { LightningElement, track, wire } from 'lwc';

import retrieveAccounts from '@salesforce/apex/PaginationController.retrieveAccounts';
//define columns of the datatable
const columns = [ { label: 'Id', fieldName: 'Id' }, { label: 'Name', fieldName: 'Name' }, { label: 'Type', fieldName: 'Type' }, { label: 'BillingCountry', fieldName: 'BillingCountry' }, ]; let i=0; export default class DisplayPaginatedRecords extends LightningElement { @track page = 1; //this will initialize 1st page @track items = []; //it contains all the records. @track data = []; //data to be displayed in the table @track columns; //holds column info. @track startingRecord = 1; //start record position per page @track endingRecord = 0; //end record position per page @track pageSize = 5; //default value we are assigning @track totalRecountCount = 0; //total record count received from all retrieved records @track totalPage = 0; //total number of page is needed to display all records @wire(retrieveAccounts) wiredAccounts({ error, data }) { if (data) { //if you want to perform data transformation then following code will be used, //so that individual values to be assigned into each columns /* for(i=0; i<data.length; i++) { this.items = [...this.items, {Id:data[i].Id, Name:data[i].Name, Type:data[i].Type, BillingCountry:data[i].BillingCountry}]; } */ this.items = data; this.totalRecountCount = data.length; //here it is 23 this.totalPage = Math.ceil(this.totalRecountCount / this.pageSize); //here it is 5 //initial data to be displayed -----------> //slice will take 0th element and ends with 5, but it doesn't include 5th element //so 0 to 4th rows will be displayed in the table this.data = this.items.slice(0,this.pageSize); this.endingRecord = this.pageSize; this.columns = columns; this.error = undefined; } else if (error) { this.error = error; this.data = undefined; } } //clicking on previous button this method will be called previousHandler() { if (this.page > 1) { this.page = this.page - 1; //decrease page by 1 this.displayRecordPerPage(this.page); } } //clicking on next button this method will be called nextHandler() { if((this.page<this.totalPage) && this.page !== this.totalPage){ this.page = this.page + 1; //increase page by 1 this.displayRecordPerPage(this.page); } } //this method displays records page by page displayRecordPerPage(page){ /*let's say for 2nd page, it will be => "Displaying 6 to 10 of 23 records. Page 2 of 5" page = 2; pageSize = 5; startingRecord = 5, endingRecord = 10 so, slice(5,10) will give 5th to 9th records. */ this.startingRecord = ((page -1) * this.pageSize) ; this.endingRecord = (this.pageSize * page); this.endingRecord = (this.endingRecord > this.totalRecountCount) ? this.totalRecountCount : this.endingRecord; this.data = this.items.slice(this.startingRecord, this.endingRecord); //increment by 1 to display the startingRecord count, //so for 2nd page, it will show "Displaying 6 to 10 of 23 records. Page 2 of 5" this.startingRecord = this.startingRecord + 1; } }


Let's take paginator component.


paginator.html


This html contains two button Previous and Next for firing events.


<template>
    <lightning-layout>
        <lightning-layout-item>
            <lightning-button label="Previous" icon-name="utility:chevronleft" onclick={previousHandler}></lightning-button>
        </lightning-layout-item>
        <lightning-layout-item flexibility="grow"></lightning-layout-item>
        <lightning-layout-item>
            <lightning-button label="Next" icon-name="utility:chevronright" icon-position="right" onclick={nextHandler}></lightning-button>
        </lightning-layout-item>
    </lightning-layout>
</template>

paginator.js

This js controller dispatches two events from event handlers.


// paginator.js
import { LightningElement } from 'lwc';

export default class Paginator extends LightningElement {
    previousHandler() {
        this.dispatchEvent(new CustomEvent('previous'));
    }

    nextHandler() {
        this.dispatchEvent(new CustomEvent('next'));
    }
}


displayPaginatedRecords.js-meta.xml

This meta xml contains the information as to where this component will be exposed.


<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata" fqn="displayPaginatedRecords">
    <apiVersion>46.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
    </targets>
</LightningComponentBundle>

Now, from Lightning App Builder page, drag and drop the displayPaginatedRecords component and open the page.


Final Outcome



Finally, we are done and thanks for reading.

Further Reading