BlogSalesforce

Salesforce Lightning Drag and Drop Multiselect Component

By May 3, 2017 May 7th, 2019 4 Comments
Salesforce Lightning needs a few more components – here’s a new drag and drop Multiselect component

Ok, I know I’ve blogged about components a lot recently… but they are pretty fun to build, so… I made another one.

This one is like the two column select components of Salesforce Classic where you can drag items in an out of the “Selected” area. Except it’s better 😉

This component allows drag and drop in both directions, Shift-Selection of multiple items to push in either direction and easy up/down arrow rearranging of selected item order. I think you’ll like it.

For some of my other components, please look here:

This component relies heavily on the HTML 5 drag and drop API, which, while it has a few quirks, seems to work well. It’s useful to use it because it abstracts away some of the mouseX and mouseY location details that you’d normally have to worry about.

For more about this API look here.

The main structure of the component is really just two lists – one with the source items and one with the destination items.

Most of the functionality is centered around adding and removing items from these lists.
Since the Lightning ‘aura:iteration’ component can only take plain arrays, I opted to iterate on Arrays of objects, sorted by a ‘sort’ attribute.

I built up a low-level addition/removal/sort set of functions in the handler that may be useful in other apps.

List Handling Building Blocks


    //this inserts an item by either swapping or pushing up/down all other items
    insertItemAt : function (fromItem,toItem,items){
      var fromIndex = fromItem.sort;
      var toIndex = toItem.sort;
    
      if (fromIndex == toIndex){
        return items;
      }
      if (Math.abs(fromIndex-toIndex) == 1){  //just swap
        var temp = fromItem.sort;
        fromItem.sort = toItem.sort;
        toItem.sort = temp;
      }
      else if (fromIndex>toIndex){
        items.forEach( function(item){
          if (item.sort >= toIndex){
            item.sort++;
          }
        });
        fromItem.sort = toIndex;
      }
      else if (toIndex>fromIndex){
        items.forEach( function(item){
          if (item.sort <= toIndex && item.sort > fromIndex){
            item.sort--;
          }
        });
        fromItem.sort = toIndex;
      }
      return this.sortItems(items);
    },
    
    //this gets an item based on the supplied index
    getItem : function(indexVar,items) {
      var itemToReturn;
      items.forEach( function(item){
        if (item.sort == indexVar){
          itemToReturn = item;
        }
      });
      return itemToReturn;
    },
    
    //this function pushes an item to the source array, and removes it from the source
    moveItemTo : function(source,destination,item,addTo){
      item.type = addTo;
      item.style = '';
      //if we put back to the source, we'll grab this sort and reinstate it.
      if (addTo == 'destination'){
        item.sort = item.savedSort;
      }
      else {
        item.savedSort = item.sort;
      }
      source = this.removeItem(item.sort,source);
      destination.push(item);
    
      return item;
    },
    
    //this gets all items between a start and end index
    getItems : function(start,end,items) {
      var itemsToReturn = [];
      items.forEach( function(item){
        if (item.sort >= start && item.sort <= end){ itemsToReturn.push(item); } }); return itemsToReturn; }, //removes an item with the specified index removeItem : function(indexVar,items) { items.forEach(function(item, index) { if (item.sort == indexVar) { items.splice(index, 1); } }); return items; }, //performs a standard numeric array sort sortItems : function(items) { items.sort(function(a, b) { return a.sort > b.sort ? 1 : -1;
      });
      return items;
    },
    
    //after a sort, renumbers items (ie removes gaps in the sort) using the iterator index
    renumberItems : function(items) {
      items = this.sortItems(items);
      items.forEach(function(item, index) {
        item.sort = index;
      });
      return items;
    },
    
    //removes items from the source that are present in dest
    xorSourceItems : function(source,dest) {
      var itemsToReturn = [];
      source.forEach(function(sourceItem, sourceIndex) {
        var match = false;
        dest.forEach(function(destItem, destIndex) {
          if (destItem.value == sourceItem.value) {
            match = true;
          }
        });
        if (!match){
            itemsToReturn.push(sourceItem)
        }
      });
      return itemsToReturn;
    }

Design Rationale

By having the set of list handling utility methods above, Once they were (hopefully) bug free, I could use them to easily manipulate items in my lists. They are used by the higher functions extensively to perform all actions in the app.

I really wanted to truly abstract the two lists into two instances of the same component, but I thought that Locker Service would break the drag and drop API if I made it out of components. Hence, in the main app, I have to lists defined, source and destination.

This was… unsettling to me, as I like componentization, reusability and abstraction… so in the interests of abstraction, I tried to make the helper code as unaware as possible of the actual lists it was operating on.

Below you can see a generic `add` method that can be used for either the left or right hand list:
(as you can see, there is a little leakage of information (check for ‘source’ / ‘destination’) – I might be able to remove this in a later iteration).

Perhaps the best way of avoiding knowledge would be to pass control back to the controller and get the controller to handle these details.

An Example of Helper Abstraction

   init: function(component, event, helper) {

    handleAddItems : function(component,event, sourceName, destinationName, selectedListName, selectedItemName,addTo){
    
      var itemsHighlight = component.get(selectedListName);
      var itemHighlight = component.get(selectedItemName);
      
      //source list (nominally lhs)
      var source = component.get(sourceName);
      //destination list (nominally rhs)
      var destination = component.get(destinationName);
      
      //if we are selecting multiple items
      if (!itemsHighlight.length && itemHighlight){
        itemsHighlight.push(itemHighlight)
      }
      //something went wrong
      else if(!itemsHighlight.length && !itemHighlight){
        return;
      }
      var self = this;
      //one by one, move the items
      itemsHighlight.forEach(function(item){
        self.moveItemTo(source,destination,item,addTo);
      });
      
      //we never want to renumber the true source (lhs)
      if (addTo == 'source'){
        source = this.renumberItems(source);
      }
      else {
        destination = this.renumberItems(destination);
      }
      source = this.sortItems(source);
      destination = this.sortItems(destination);
      
      //write all values back
      component.set(sourceName,source);
      component.set(destinationName,destination);
      this.broadcastDataChange(component);
      
    },
In the controller, there are few “dummy” event handlers – these are just there to make the drag and drop API work, even though they are not used.

    
    handleOnDragOverDummy: function(component, event, helper) {
        event.preventDefault();
      },
      handleOnDragEnterDummy: function(component, event, helper) {
        event.preventDefault();
      },
      handleOnDragLeaveDummy: function(component, event, helper) {
        event.preventDefault();
      },
The source code is not officially available from LightningDesignSystem.com… but it’s pretty easy to scrape it.

Locker Service

One great thing about how this app works is that it’s all pretty much standard HTML.
Salesforce using LockerService now has secure versions of most elements and attributes, so you really can just apply business as usual.
You just have to remember to respect encapsulation and to use events and public component APIs (via publicly defined `method` attributes).
As long as you do this, Locker Service won’t get in your way.

I did run into one issue caused specifically by Lightning Locker Service: all properties for an Object must be specified the first time that Object is written to.

For example, I pass in some “init” lists to the Select component and I then add some new attributes – namely: sort, type and style.
I was force to utilize JSON parsing to separate the Objects from their references, because simply copying the object to the internal representation of that object prevented bindings from working.

    selectedItems.forEach( function(item,index){
      item.sort = index;
      item.type = 'destination';
      //need to define style here, although not used till later
      item.style = '';
    });

    //needed to make work with locker service otherwise bindings don't work :(
    items = JSON.parse(JSON.stringify(items));
    selectedItems = JSON.parse(JSON.stringify(selectedItems));
To test, try with this app:

[

    
    <aura:application access="global" extends="force:slds" >
      <aura:attribute name="stagenames" type="Object[]" default="[ { 'label': 'In Credit Repair', 'value': 'In Credit Repair' }, { 'label': 'Annual Review', 'value': 'Annual Review' }, { 'label': 'Watching Prices', 'value': 'Watching Prices' }, { 'label': 'Initial Contact', 'value': 'Initial Contact' }, { 'label': 'Application', 'value': 'Application' }, { 'label': 'Waiting for Docs', 'value': 'Waiting for Docs' }, { 'label': 'Qualifying Docs Review', 'value': 'Qualifying Docs Review' }, { 'label': 'Additional Docs Requested', 'value': 'Additional Docs Requested' }, { 'label': 'Searching for Data', 'value': 'Searching for Data' }, { 'label': 'Submit to Admin', 'value': 'Submit to Admin' }, { 'label': 'Active', 'value': 'Active' }, { 'label': 'Contract Sent', 'value': 'Contract Sent' }, { 'label': 'Waiting on interested Party', 'value': 'Waiting on interested Party' } ]"/>
    
      <aura:attribute name="stagename" type="Object[]" default="[ { 'label': 'In Credit Repair', 'value': 'In Credit Repair' }, { 'label': 'Annual Review', 'value': 'Annual Review' }, { 'label': 'Watching Prices', 'value': 'Watching Prices' }]"/>
        
      
<div class="slds">

<div class="slds-box">
          <c:SPEAR_MultiColumnSelect fieldName="Opportunity Stage" allValues="{!v.stagenames}" selectedValues="{!v.stagename}" />
        </div>

     
      </div>

    </aura:application>

Here’s what it looks like:

Multi Column Select in Action

That’s all for now.

Enjoy!

Caspar Harmer

Caspar Harmer

Caspar is a New Zealander working for Soliant from far-off Wellington. He loves exploring new technologies and solving problems. Caspar also loves getting into the outdoors; he runs, mountain bikes and does a lot of orienteering when he can fit it in.

4 Comments

Leave a Reply