Kanban using Lightning Components

Not sure if you have seen my old blog post that I wrote like 2 years ago on building a Lead Kanban (when actually Lightning Components just took off) using Visualforce Pages – https://shrutisridharan.wordpress.com/2016/08/22/creating-a-kanban-view-for-leads/

I am still a fan of Visualforce Pages and given a chance I would still develop in Visualforce Pages. But yes things have changed and adding Lightning Development Skills to your toolbelt has become inevitable! There is so much demand for Lightning Developers these days and it’s a mandatory skill to have on your Resume.

That being said, I thought it would be a great start to step into Lightning by polishing my old classic Lead Kanban and re-building it using Lightning Components. And luckily I was successful. It wasn’t easy(I swear!) and it indeed was a roller coaster ride. So let me take you all into the ride and I am sure at the end you are gonna love what Lightning Components can offer!

Why Build a Kanban?!?

So the first thing that would strike you is – Why should I even develop the old Lead Kanban(built using Visualforce Pages) in Lightning when Salesforce offers a Kanban View out of the box ?

So here is the answer to it –

  1. To get a hang of Lightning Components Framework
  2. To understand how external libraries like jQuery, jQuery UI etc. plays along with the Lightning Component Framework
  3. To learn about standard Lightning Events
  4. You can customize it however you want – add buttons for example!
  5. And last – to have some fun!

So let’s get started!

Sneak Peek

Screenshot_1

So that’s how the Kanban would look like in Lightning at the end 🙂 Now, at first glimpse you would have that typical “everything in here should be a component feeling“! Well I say it isn’t wrong! Lightning Component is indeed about breaking down your page or app into indivisible components and then building the App by tieing them all together just like how you would build using your Lego Bricks.

But I have felt that this practice of decomposing is not required all the time. It’s much easier when you don’t decompose into multiple sub-components. The additional overhead that would come your way when you have multiple components is facilitating the interaction between all of them using Custom Events. So for now let’s build this thing as a single component and keep it as simple and lucid as possible.

Apex Controller

public class LeadLightningKanbanController {
public static final String LEAD_MOVED = '{0} was moved successfully to {1} and column ordering was updated.';
//////////////////
//Aura Handlers //
//////////////////
@AuraEnabled
public static DataFetchResult initialize() {
DataFetchResult result = new DataFetchResult();
getLeadStatuses( result );
fetchLeads( result );
return result;
}
@AuraEnabled
public static StatusUpdateResult updateLeadStatus( Id leadId, String newLeadStatus, List<String> ordering ) {
try {
Lead lead = [
SELECT Id
,Name
FROM Lead
WHERE Id = :leadId
];
lead.Status = newLeadStatus;
UPDATE lead;
/**
* Update all the Lead records
* with the new ordering.
*/
List<Lead> leadsToUpdate = new List<Lead>();
Integer counter = 1;
for( String recId : ordering ) {
leadsToUpdate.add( new Lead( Id = recId, Kanban_Sort_Order__c = counter++ ) );
}
UPDATE leadsToUpdate;
return new StatusUpdateResult(
TRUE,
'Success',
String.format( LEAD_MOVED, new List<String>{ lead.Name, newLeadStatus } )
);
}
catch( Exception ex ) {
return new StatusUpdateResult(
FALSE,
'Error',
ex.getMessage()
);
}
}
///////////////////
//Public Classes //
///////////////////
public class Column {
@AuraEnabled
public String label { get; set; }
@AuraEnabled
public Boolean isDropEnabled { get; set; }
public Column( String label, Boolean isDropEnabled ) {
this.label = label;
this.isDropEnabled = isDropEnabled;
}
}
public class DataFetchResult {
@AuraEnabled
public List<Column> columns { get; set; }
@AuraEnabled
public Map<String, List<Lead>> rows { get; set; }
}
public class StatusUpdateResult {
@AuraEnabled
public Boolean isSuccess { get; set; }
@AuraEnabled
public String title { get; set; }
@AuraEnabled
public String message { get; set; }
public StatusUpdateResult( Boolean isSuccess, String title, String message ) {
this.isSuccess = isSuccess;
this.title = title;
this.message = message;
}
}
///////////////////////////
//Private Helper Methods //
///////////////////////////
static void getLeadStatuses( DataFetchResult result ) {
result.columns = new List<Column>();
LeadKanbanSettings__c settings = LeadKanbanSettings__c.getInstance( UserInfo.getOrganizationId() );
Set<String> dropProhibtedCols = new Set<String>();
if( settings.Drop_Prohibited_Columns__c != NULL ) {
dropProhibtedCols.addAll( settings.Drop_Prohibited_Columns__c.split( ',' ) );
}
for( LeadStatus status : [
SELECT Id
,ApiName
,SortOrder
,MasterLabel
FROM LeadStatus
ORDER BY SortOrder ASC
]
) {
result.columns.add(
new Column(
status.MasterLabel,
!dropProhibtedCols.contains( status.MasterLabel )
)
);
}
}
static void fetchLeads( DataFetchResult result ) {
List<Lead> leads = [
SELECT Id
,Name
,Title
,Company
,Email
,LeadSource
,Status
,Kanban_Sort_Order__c
FROM Lead
ORDER BY Kanban_Sort_Order__c ASC
];
result.rows = new Map<String, List<Lead>>();
for( Column col : result.columns ) {
result.rows.put( col.label, new List<Lead>{} );
}
for( Lead lead : leads ) {
if( result.rows.containsKey( lead.Status ) ) {
result.rows.get( lead.Status ).add( lead );
}
}
}
}

At the heart, the initialize() method returns the columns for our Kanban Board and also the Lead Records. The Lead Records are grouped with respect to the LeadStatus and is contained within a Map. The DataFetchResult class encapsulates all these information and is returned back to the Component from the initialize method. On the other hand the updateLeadStatus() does two things – 

  1. Changes the LeadStatus on the Lead
  2. Also updates order of appearance of a Lead within a lane or  column on the Kanban Board

Drop Prohibited Columns

A new feature that isn’t available in the old Classic Kanban but I have introduced in the Lightning Component is – Drop Prohibition. There could be instances where the Sales Users are not allowed to drag and drop a Lead into a certain status. This is achieved by setting up a Custom Setting wherein the Admins can specify the prohibited statuses as comma separated values like as shown below –

 

Screenshot_2

Component Markup

<aura:component controller="LeadLightningKanbanController" implements="force:appHostable,flexipage:availableForAllPageTypes" access="global" >
<!–Scripts–>
<ltng:require scripts="/resource/jQuery,/resource/jQueryUI" afterScriptsLoaded="{!c.init}"/>
<!–Internal Attributes–>
<aura:attribute name="rows" type="List"/>
<aura:attribute name="columns" type="List"/>
<aura:attribute name="scriptsLoaded" type="Boolean" default="false"/>
<aura:attribute name="sortableApplied" type="Boolean" default="false"/>
<!–Handlers–>
<aura:handler name="render" value="{!this}" action="{!c.applySortable}"/>
<!–Spinner–>
<lightning:spinner aura:id="spinner" alternativeText="Loading" size="large" />
<!–Kanban Column Headers–>
<div class="slds-grid">
<div class="slds-tabs–path" role="application">
<ul class="slds-tabs–path__nav" role="tablist">
<aura:iteration items="{!v.columns}" var="col">
<li class="slds-tabs–path__item slds-is-incomplete" role="presentation">
<a class="slds-tabs–path__link" tabindex="-1" role="tab" href="javascript:void(0);">
<aura:if isTrue="{! !col.isDropEnabled }">
<lightning:helptext content="You cannot drop cards into this column!" class="slds-m-right_small"/>
</aura:if>
<span class="slds-tabs–path__title slds-text-heading–medium">{!col.label}</span>
</a>
</li>
</aura:iteration>
</ul>
</div>
</div>
<!–Kanban Columns–>
<div class="slds-grid">
<aura:iteration items="{!v.rows}" var="row">
<div class="slds-col slds-lane slds-has-dividers–around-space slds-scrollable_y" data-name="{!row.key.label}" data-drop-enabled="{!row.key.isDropEnabled}">
<aura:iteration items="{!row.value}" var="lead">
<div class="slds-item slds-m-around–small" data-id="{!lead.Id}" data-status="{!lead.Status}">
<div class="slds-tile slds-tile–board">
<h3 class="slds-section-title–divider slds-m-bottom–x-small slds-title slds-theme_shade">
<div class="slds-grid">
<div class="slds-col">
<div class="slds-m-top_x-small">
<a href="{!'/' + lead.Id}" target="_blank">{!lead.Name}</a>
</div>
</div>
<div class="slds-col slds-text-align_right">
<lightning:buttonGroup >
<lightning:buttonIcon iconName="utility:call" variant="border-filled" alternativeText="Log A Call" onclick="{!c.logACall}" name="{!lead.Id}" />
<lightning:buttonIcon iconName="utility:event" variant="border-filled" alternativeText="Log A Meeting" onclick="{!c.logAMeeting}" name="{!lead.Id}" />
</lightning:buttonGroup>
</div>
</div>
</h3>
<div class="slds-tile__detail slds-text-body–small">
<p class="slds-truncate">
{!lead.Title}
</p>
<p class="slds-truncate">
{!lead.Company}
</p>
<p class="slds-truncate">
{!lead.Email}
</p>
<p class="slds-truncate">
{!lead.LeadSource}
</p>
</div>
</div>
</div>
</aura:iteration>
</div>
</aura:iteration>
</div>
</aura:component>

view raw
LeadKanban.html
hosted with ❤ by GitHub

The afterScriptsLoaded event on the ltng:require component kicks-off a chain of actions. We will get to the actions a bit later. Let’s look at the Column/Lane Headings of our Kanban Board.

The columns headers are displayed using the Path Component from the Lightning Design System. An aura:iteration iterates through the list columns and repeats the markup for each item on the Path Component.

Each column is rendered using the Lightning Design Grid System framework.

The most important tag to make a note of would be –

<aura:handler name="render" value="{!this}" action="{!c.applySortable}"/>

This handler listens to the standard render event and then fires the applySortable method in order to enable the drag and drop using jQuery UI.

It’s very important to note that this event is fired multiple times during the rendering life-cycle and at various instances such as when the DOM is updated as a result of dirty-checking and so on. So it’s mandatory that we put restrictions in place to ensure that the handler(i.e., applySortable) isn’t executed MANY times and ONLY when it’s needed.

Component Controller

( {
init : function( component, event, helper ) {
var action = component.get( "c.initialize" );
component.set( "v.scriptsLoaded", true );
action.setCallback( this, function( response ) {
var state = response.getState();
if ( state === "SUCCESS" ) {
var result = response.getReturnValue();
var cols = {};
result.columns.forEach(
function( col ) {
cols[col.label] = col;
}
);
var rows = [];
for( var key in result.rows ) {
rows.push( { value: result.rows[key], key: cols[key] } );
}
component.set( "v.rows", rows );
component.set( "v.columns", result.columns );
helper.hideSpinner( component );
}
else {
helper.showToast(
{
"title" : "Error",
"message" : "Error: " + JSON.stringify( response.getError() ) + ", State: " + state,
"isSuccess" : "error"
}
);
}
} );
$A.enqueueAction( action );
},
applySortable : function( component, event, helper ) {
var sortableApplied = component.get( "v.sortableApplied" );
var scriptsLoaded = component.get( "v.scriptsLoaded" );
/**
* Apply the jQuery Sortable
* when the DOM is ready and
* the Scripts have been loaded.
*/
if( scriptsLoaded &&
!sortableApplied &&
jQuery( ".slds-lane" ).length > 0
) {
component.set( "v.sortableApplied", true );
helper.applySortable( component );
}
},
logACall : function( component, event, helper ) {
var createRecordEvent = $A.get( "e.force:createRecord" );
createRecordEvent.setParams(
{
"entityApiName" : "Task",
"defaultFieldValues": {
"WhoId" : event.getSource().get( "v.name" ),
"Subject" : "Log A Call"
}
}
);
createRecordEvent.fire();
},
logAMeeting : function( component, event, helper ) {
var createRecordEvent = $A.get( "e.force:createRecord" );
createRecordEvent.setParams(
{
"entityApiName" : "Event",
"defaultFieldValues": {
"WhoId" : event.getSource().get( "v.name" ),
"Subject" : "Log A Meeting"
}
}
);
createRecordEvent.fire();
}
} )

The init method in the constructor is responsible for invoking the method from the Apex Controller that would return an instance of the DataFetchResult class that encapsulates the Columns and the Records.

Now we have a problem! Like as I mentioned before, the records are populated as a Map (grouped by LeadStatus) which would be represented as an Object in JavaScript. We cannot use the aura:iteration to loop over the keys of an object(this is such a deal breaker!). Thus we iterate the keys of the Object(which was a Map in Apex) to convert it to a linear array for the aura:iteration to work.

var rows = [];
for( var key in result.rows ) {
    rows.push( { value: result.rows[key], key: cols[key] } );
}

As discussed earlier, the applySortable function is responsible for applying the jQuery Sortable to our Kanban Columns thus making it function like a Trello Board(that drag and drop fantasy!). Now before even you start using jQuery to manipulate your DOM, you need to remember three things:

  1. Ensure your Scripts(jQuery Libraries) are loaded
  2. The DOM is ready(so damn important)
  3. You haven’t applied Sortable already

And thus we have all these checks in place –

/**
 * Apply the jQuery Sortable
 * when the DOM is ready and 
 * the Scripts have been loaded.
 */
if( scriptsLoaded && 
 !sortableApplied && 
 jQuery( ".slds-lane" ).length > 0
) {
   component.set( "v.sortableApplied", true );
 
   helper.applySortable( component );
}

Helper

({
hideSpinner : function( component ) {
var eleSpinner = component.find( "spinner" );
$A.util.addClass( eleSpinner, "slds-hide" );
},
showSpinner : function( component ) {
var eleSpinner = component.find( "spinner" );
$A.util.removeClass( eleSpinner, "slds-hide" );
},
showToast : function( data ) {
var toastEvent = $A.get( "e.force:showToast" );
toastEvent.setParams(
{
duration : 2000,
title : data.title,
message : data.message,
type : data.type ? data.type : (data.isSuccess ? "success" : "error")
}
);
toastEvent.fire();
},
applySortable : function( component ) {
var helper = this;
jQuery( ".slds-lane" ).sortable(
{
revert : true,
connectWith : ".slds-lane",
handle : ".slds-title",
placeholder : "slds-item slds-m-around–small slds-item-placeholder"
}
);
jQuery( ".slds-lane" ).on(
"sortstart",
$A.getCallback(
function( event, ui ) {
jQuery( ui.item ).addClass( "moving-card" );
}
)
);
jQuery( ".slds-lane" ).on(
"sortstop",
$A.getCallback(
function( event, ui ) {
jQuery( ui.item ).removeClass( "moving-card" );
var leadId = $( ui.item ).data( "id" );
var oldLeadStatus = $( ui.item ).data( "status" );
var newLeadStatus = $( ui.item ).parent().data( "name" );
var isDropEnabled = $( ui.item ).parent().data( "drop-enabled" );
/**
* If the cards were dropped
* into a prohibited column
* and if the action was not
* just a re-ordering then
* thrown an error!
*/
if( !isDropEnabled && oldLeadStatus !== newLeadStatus ) {
jQuery( ".slds-lane" ).sortable( "cancel" );
helper.showToast( {
isSuccess : false,
title : "Prohibited",
message : "You cannot move cards into this column. Action has been reverted."
} );
}
else {
helper.showSpinner( component );
var action = component.get( "c.updateLeadStatus" );
var params = {
"leadId" : leadId,
"newLeadStatus" : newLeadStatus,
"ordering" : []
};
/**
* Maintain the ordering within
* the lane.
*/
$( ui.item ).parent().children().each(
function() {
params.ordering.push( $( this ).data( "id" ) );
}
);
action.setParams( params );
action.setCallback(
this,
function( response ) {
var state = response.getState();
helper.hideSpinner( component );
if( state === "SUCCESS" ) {
var updateStatus = response.getReturnValue();
/**
* Show a separate message
* if the cards were just
* re-arranged within the
* same column.
*/
if( oldLeadStatus === newLeadStatus ) {
updateStatus.type = "info";
updateStatus.message = "Column Ordering was Updated.";
}
$( ui.item ).attr( "data-status", newLeadStatus );
helper.showToast( updateStatus );
}
}
);
$A.enqueueAction( action );
}
}
)
);
}
})

view raw
LeadKanbanHelper.js
hosted with ❤ by GitHub

The helper houses some utility methods such as show/hideSpinner, showToasts and the applySortable that is responsible for applying jQuery Sortable to the columns or lanes.

Now the most important thing to note while working with libraries like jQuery is the usage of –

$A.getCallback()

We need to make sure all the jQuery Event Handlers are wrapped within $A.getCallback since these libraries executes and modifies the DOM outside the rendering context of the Lightning Component Framework.

The Wrap!

Voila! That’s all you need to get this up and running. To make it easier, I have created an Unmanaged Package that you can install to bring all this Kanban goodness on the click of a button –

https://login.salesforce.com/packaging/installPackage.apexp?p0=04t0I000000qqzY

Here is a video that shows the Kanban in action –

 

 

10 thoughts on “Kanban using Lightning Components

    1. Yes, absolutely. Do you have anything started yet ? I could build upon that and give you some hints.

      Like

  1. thanks for putting this together. i went down a kanban rabbit hole for an idea and this being easy to get up and running led me in some interesting directions. its also a final push to commit to components. keep on making cool shit!

    Like

  2. Hi Shruti, can this kanban view auto refresh. Say i have two teams working on the list of leads and one of them updates the record and it moves to different stage, will the data be reflected for both the users.

    Like

    1. Hello! Thanks for reading my blog. So if they have the tab open, NO, they will not see the change unless they refresh it. In case if you want to do this, you will have to use the Streaming API along with the lightning:empApi component to make this possible. Please let me know if you need any directions on how to achieve this.

      Like

  3. Hi Shruti,
    I appreciate your efforts in making this cool awesome kanban, I was looking for a similar one for my project.
    I am very new to salesforce and there by to lightning components, though I was able to understand the implementation details. I see that you are loading jQuery files from the resource folder – is it possible to share the files that have to be loaded ? or is it already mentioned n the blog? (if yes sorry for not noticing 🙂 )
    Expecting your reply, thanks !

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s