Salesforce Lightning Drag and Drop Multiselect Component

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();
  },

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.

Get the Component Files

Enjoy!

4 thoughts on “Salesforce Lightning Drag and Drop Multiselect Component”

    1. Not yet… but I will likely convert it in the near future. When I do, I’ll make a blog post about the conversion process.

Leave a Comment

Your email address will not be published.