Sunday, April 19, 2020

Easy way of building Multi-select Lookup Component using Lightning Web Components

Motivation behind this


I have received a requirement on multiple selection of items along with lookup functionality. I have gone through several posts but didn't find this unique matching requirements which drives me to develop quick prototype.

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, encountered few challenges when using lightning-checkbox-group and gained knowledge on solving those which I want to share right here.

Let's get started.

Use Case


Business has a requirement to select multiple items (as a checkbox) along with lookup search functionality. This component can be leveraged for similar requirements.

Possible End Result


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



Solution Approach


First, create main component Multi-select Lookup and use that in Parent component. After choosing items and clicking on Done button, event will be propagated to parent component to display messages. Similarly, removing an item from pill will fire event to parent component.

lwcMultiSelectLookup component:

lwcMultiSelectLookup.html will prepare a screen like this:


HTML has been divided into 3 parts:
  • Lightning-input for searching
  • Lightning-pill to display selected items
  • Lightning-checkbox-group for multiple selection which has been used inside dialog
Refer Lightning Design System - Combobox for CSS and component placement purpose.

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
<template>
 <div class="slds-form-element">
  <label class='slds-m-around_x-small'><small>{labelName}</small></label>
  <div class="slds-form-element__control">
              <div class="slds-combobox_container">
   <div class="slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click slds-is-open" 
    aria-expanded="true" aria-haspopup="listbox" role="combobox">
                    <div class="slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click slds-has-focus" 
                        role="none">
   <lightning-input id="input" 
    value={searchInput} onchange={onchangeSearchInput} variant="label-hidden" aria-autocomplete="list" role="textbox"
    autocomplete="off" placeholder="Search..." type="search">
   </lightning-input>
                    </div>
                    <div class="slds-form-element__control slds-input-has-icon slds-input-has-icon slds-input-has-icon_left-right" role="none">
                        <template for:each={globalSelectedItems} for:item="selectedItem">
                            <span key={selectedItem.value}>
                                <lightning-pill label={selectedItem.label} name={selectedItem.value} data-item={selectedItem.value}
                                    onremove={handleRemoveRecord}>
                                    <lightning-icon icon-name={iconName} variant="circle" 
                                        alternative-text={selectedItem.label}></lightning-icon>
                                </lightning-pill>
                            </span>
                        </template>                     
                    </div>
                    <template if:true={isDisplayMessage}>
                        <lightning-card>No records found.</lightning-card>
                    </template> 
                    <template if:false={isDisplayMessage}>
                        <template if:true={isDialogDisplay}>
                            <section aria-describedby="dialog-body-id-26" aria-label="Language Options" 
    class="slds-popover slds-popover_full-width" id="popover-unique-id-02" role="dialog">
                                <div class="slds-popover__body slds-popover__body_small" id="dialog-body-id-26">
                                    <fieldset class="slds-form-element">   
                                        <lightning-checkbox-group name="Checkbox Group"
                                            label={objectAPIName}
                                            options={items}
                                            value={value}
                                            onchange={handleCheckboxChange}>
                                        </lightning-checkbox-group>
                                    </fieldset>
                                </div>
                                <footer class="slds-popover__footer slds-popover__footer_form">
                                    <lightning-button label="Cancel" title="Cancel" 
                                            onclick={handleCancelClick} class="slds-m-left_x-small"></lightning-button>
                                    <lightning-button variant="success" label="Done" title="Done"
                                            onclick={handleDoneClick} class="slds-m-left_x-small"></lightning-button>                                
                                </footer>
                            </section>                                               
                        </template>
                    </template>
               </div>
          </div>
      </div>
  </div>
</template>

lwcMultiSelectLookup.js

This js controller is most important for implementing this functionality and some of the key points have been highlighted below:

  • This component will be called from parent component, so following parameters can be passed into this which have been used with @api 
@api labelName;
@api objectApiName; // = 'Contact';
@api fieldApiNames; // = 'Id,Name';
@api filterFieldApiName;    // = 'Name';
@api iconName;  // = 'standard:contact';
  • To prepare the list of items from database call used spread operator.
this.items = [...this.items,{value:resElement.recordId, 
                             label:resElement.recordName}];

  • As previously selected combo box items to be hold and shown as selected, so it will loop through the items from dataset and prepare the array. Here map function is used for loop, instead for traditional for loop.
this.globalSelectedItems.map(element =>{
 if(element.value == resElement.recordId){
  this.value.push(element.value);
  this.previousSelectedItems.push(element);                      
 }
});

  • handleCheckboxChange method will prepare selectedItems array, here map and find functions have been used for performance benefit.
  • handleRemoveRecord method removes the item from array using filter function.
this.globalSelectedItems = this.globalSelectedItems.filter(item => item.value!= removeItem);
  • Event has been propagated to parent component as follows:
const evtCustomEvent = new CustomEvent('remove', {   
            detail: {removeItem,arrItems}
            });
this.dispatchEvent(evtCustomEvent);

  • handleDoneClick method removes the previousSelectedItems and then adds entire selectedItems into it. Here spread operator is used.
this.globalSelectedItems.push(...this.selectedItems); 

  • Challenges: As combobox group doesn't expose any event other than change, so it is difficult to identify which item has been removed. For this previous selection to stored and it needs to be compared with current selection.
  • Retrieve event is propagated to parent component just like remove.
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
/*
 *   Author: Santanu Boral
*/
import { LightningElement, api, track } from 'lwc';
import retrieveRecords from '@salesforce/apex/MultiSelectLookupController.retrieveRecords';

let i=0;
export default class LwcMultiSelectLookup extends LightningElement {

    @track globalSelectedItems = []; //holds all the selected checkbox items
    //start: following parameters to be passed from calling component
    @api labelName;
    @api objectApiName; // = 'Contact';
    @api fieldApiNames; // = 'Id,Name';
    @api filterFieldApiName;    // = 'Name';
    @api iconName;  // = 'standard:contact';
    //end---->
    @track items = []; //holds all records retrieving from database
    @track selectedItems = []; //holds only selected checkbox items that is being displayed based on search

    //since values on checkbox deselection is difficult to track, so workaround to store previous values.
    //clicking on Done button, first previousSelectedItems items to be deleted and then selectedItems to be added into globalSelectedItems
    @track previousSelectedItems = []; 
    @track value = []; //this holds checkbox values (Ids) which will be shown as selected
    searchInput ='';    //captures the text to be searched from user input
    isDialogDisplay = false; //based on this flag dialog box will be displayed with checkbox items
    isDisplayMessage = false; //to show 'No records found' message
    
    //This method is called when user enters search input. It displays the data from database.
    onchangeSearchInput(event){

        this.searchInput = event.target.value;
        if(this.searchInput.trim().length>0){
            //retrieve records based on search input
            retrieveRecords({objectName: this.objectApiName,
                            fieldAPINames: this.fieldApiNames,
                            filterFieldAPIName: this.filterFieldApiName,
                            strInput: this.searchInput
                            })
            .then(result=>{ 
                this.items = []; //initialize the array before assigning values coming from apex
                this.value = [];
                this.previousSelectedItems = [];

                if(result.length>0){
                    result.map(resElement=>{
                        //prepare items array using spread operator which will be used as checkbox options
                        this.items = [...this.items,{value:resElement.recordId, 
                                                    label:resElement.recordName}];
                        
                        /*since previously choosen items to be retained, so create value array for checkbox group.
                            This value will be directly assigned as checkbox value & will be displayed as checked.
                        */
                        this.globalSelectedItems.map(element =>{
                            if(element.value == resElement.recordId){
                                this.value.push(element.value);
                                this.previousSelectedItems.push(element);                      
                            }
                        });
                    });
                    this.isDialogDisplay = true; //display dialog
                    this.isDisplayMessage = false;
                }
                else{
                    //display No records found message
                    this.isDialogDisplay = false;
                    this.isDisplayMessage = true;                    
                }
            })
            .catch(error=>{
                this.error = error;
                this.items = undefined;
                this.isDialogDisplay = false;
            })
        }else{
            this.isDialogDisplay = false;
        }                
    }

    //This method is called during checkbox value change
    handleCheckboxChange(event){
        let selectItemTemp = event.detail.value;
        
        //all the chosen checkbox items will come as follows: selectItemTemp=0032v00002x7UE9AAM,0032v00002x7UEHAA2
        console.log(' handleCheckboxChange  value=', 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.items.find(element => element.value == p);
            //arr = value: "0032v00002x7UEHAA2", label: "Arthur Song
            if(arr != undefined){
                this.selectedItems.push(arr);
            }  
        });     
    }

    //this method removes the pill item
    handleRemoveRecord(event){        
        const removeItem = event.target.dataset.item; //"0032v00002x7UEHAA2"
        
        //this will prepare globalSelectedItems array excluding the item to be removed.
        this.globalSelectedItems = this.globalSelectedItems.filter(item => item.value  != removeItem);
        const arrItems = this.globalSelectedItems;

        //initialize values again
        this.initializeValues();
        this.value =[]; 

        //propagate event to parent component
        const evtCustomEvent = new CustomEvent('remove', {   
            detail: {removeItem,arrItems}
            });
        this.dispatchEvent(evtCustomEvent);
    }

    //Done dialog button click event prepares globalSelectedItems which is used to display pills
    handleDoneClick(event){
        //remove previous selected items first as there could be changes in checkbox selection
        this.previousSelectedItems.map(p=>{
            this.globalSelectedItems = this.globalSelectedItems.filter(item => item.value != p.value);
        });
        
        //now add newly selected items to the globalSelectedItems
        this.globalSelectedItems.push(...this.selectedItems);        
        const arrItems = this.globalSelectedItems;
        
        //store current selection as previousSelectionItems
        this.previousSelectedItems = this.selectedItems;
        this.initializeValues();
        
        //propagate event to parent component
        const evtCustomEvent = new CustomEvent('retrieve', { 
            detail: {arrItems}
            });
        this.dispatchEvent(evtCustomEvent);
    }

    //Cancel button click hides the dialog
    handleCancelClick(event){
        this.initializeValues();
    }

    //this method initializes values after performing operations
    initializeValues(){
        this.searchInput = '';        
        this.isDialogDisplay = false;
    }
}

MultiSelectLookupController.cls

This class fetching data from database and returns the data for js controller's array recognized format.


 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
public with sharing class MultiSelectLookupController {
    //This method retrieves the data from database table. It search input is '*', then retrieve all records
    @AuraEnabled (cacheable=true)
    public static List<SObjectQueryResult> retrieveRecords(String objectName, 
                                                    String fieldAPINames,
                                                    String filterFieldAPIName,
                                                    String strInput){
        
        List<SObjectQueryResult> lstReturnResult = new List<SObjectQueryResult>();
        if(strInput.equals('*')){
            strInput = '';
        }
        String str = strInput + '%';
        String strQueryField = '';
        List<String> fieldList = fieldAPINames.split(',');

        //check if Id is already been passed
        if(!fieldList.contains('Id')){
            fieldList.add('Id');
            strQueryField = String.join(fieldList, ',');
        }else {
            strQueryField = fieldAPINames;
        }

        String strQuery = 'SELECT ' + String.escapeSingleQuotes(strQueryField) 
                        + ' FROM ' 
                        + String.escapeSingleQuotes(objectName) 
                        + ' WHERE ' + filterFieldAPIName + '  LIKE \'' + str + '%\''
                        + ' ORDER BY ' + filterFieldAPIName
                        + ' LIMIT 50';
                        
        System.debug('strQuery=' + strQuery);

        List<SObject> lstResult = database.query(strQuery);
        //create list of records that can be easily be parsable at js controller.
        for(String strField:fieldList){
            for(SObject sobj:lstResult){                
                if(strField != 'Id'){
                    SObjectQueryResult result = new SObjectQueryResult();
                    result.recordId = (String) sobj.get('Id');
                    result.recordName = (String) sobj.get(strField);
                    lstReturnResult.add(result);
                }                
            }
        }
        return lstReturnResult;
    }
    
    public class SObjectQueryResult {
        @AuraEnabled
        public String recordId;

        @AuraEnabled
        public String recordName;
    }
}

Let's move on to parent component.

displayMultiSelectLookup component:

This component holds the lwcmultiselectLookup component, it passes required parameters as described as above and listens to the events have been propagated. It creates the message to display list of items.

displayMultiSelectLookup.html



 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
<template>
    <div class="c-container">
        <lightning-card>
            <h3 slot="title">
                <lightning-icon icon-name="utility:connected_apps" size="small"></lightning-icon>
                Lightning Web Component - Multi-Select Lookup Component
            </h3>
            <p class="slds-p-horizontal_small">
                <c-lwc-multi-select-lookup 
                    label-name="Search Contact"
                    object-api-name= "Contact"
                    field-api-names="Id,Name"
                    filter-field-api-name="Name"
                    icon-name="standard:contact"
                    onretrieve={selectItemEventHandler} 
                    onremove={deleteItemEventHandler}>
                </c-lwc-multi-select-lookup>
            </p><br><br>
            <template if:true={isItemExists}>                
                <h3 class="slds-p-horizontal_small">
                    Message from Parent Component <br/>
                    You have chosen: {selectedItemsToDisplay}
                </h3>                    
               
            </template>            
        </lightning-card> 
    </div>
</template>


displayMultiSelectLookup.js

This class is simple, it creates the message upon listening and capturing information from events.


 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
import { LightningElement, track} from 'lwc';

export default class DisplayMultiSelectLookup extends LightningElement {

    @track selectedItemsToDisplay = ''; //to display items in comma-delimited way
    @track values = []; //stores the labels in this array
    @track isItemExists = false; //flag to check if message can be displayed

    //captures the retrieve event propagated from lookup component
    selectItemEventHandler(event){
        let args = JSON.parse(JSON.stringify(event.detail.arrItems));
        this.displayItem(args);        
    }

    //captures the remove event propagated from lookup component
    deleteItemEventHandler(event){
        let args = JSON.parse(JSON.stringify(event.detail.arrItems));
        this.displayItem(args);
    }

    //displays the items in comma-delimited way
    displayItem(args){
        this.values = []; //initialize first
        args.map(element=>{
            this.values.push(element.label);
        });

        this.isItemExists = (args.length>0);
        this.selectedItemsToDisplay = this.values.join(', ');
    }
}


Screen Output:

The parent component will hold the Lookup component as well as show the message of the selected items.





Overall, it is a small piece of work but good to explore new things in Javascript and LWC. Thanks for reading!


References



Further Reading


No comments:

Post a Comment