So previously we developed a Lightning DataTable with Lazy Loading component. But there are many standard features missing in that component. So I enhanced that Lightning DataTable component and included the support for inline editing, Row Level Action and pass the selected records in parent component.
Here we can change multiple records. And on click of Save button, records will be updated. So below is how our flow will run.

Here we can easily enable and disable lazy loading and other attributes. So our components have multiple attribute to support all standard functionality. Because we are using attributes, so we can easily on-off the behaviour and don’t need to make changes in component code. For reference I have used only few attributes here. but there are many other attributes which we I will explain in below code.
DataTable.app
<aura:application extends="force:slds">
<aura:attribute name="userData" type="List" default="[]"/>
<aura:attribute name="tableHeight" type="Integer" default="450"/>
<aura:attribute name="sortedDirection" type="String" default="DESC"/>
<aura:attribute name="inlineEdit" type="boolean" default="true"/>
<aura:attribute name="enableColAction" type="boolean" default="true"/>
<c:DataTableLazyLoading selectedData="{!v.userData}" tableHeight="{!v.tableHeight}" sortedDirection="{!v.sortedDirection}" inlineEdit="{!v.inlineEdit}" enableColAction ="{!v.enableColAction}"/>
Selected Rows: <br/> <br/>
<aura:iteration items="{!v.userData}" var="item">
{!item.Id}<br/>
</aura:iteration>
</aura:application>
So this is our demo app. Here we have attributes for tableHeight, What will be the default sorted direction, If we want to keep inline editting or not and last if we want to display row level action.
DataTableController.apxc
So in class initRecords method we are passing ObjectName, Comma seperated field name, OrderBy direction and if we want to keep the inline edit and Row level action. We also have method which will delete the record and update the records. We have used multiple wrapper classes to control the attributes.
public class DataTableController {
@AuraEnabled
public static DataTableWrapper initRecords(String ObjectName, String fieldNamesStr, String Orderby, String OrderDir, boolean inlineEdit, boolean enableColAction) {
DataTableWrapper dtw = new DataTableWrapper();
List<LabelDescriptionWrapper> labelList = new List<LabelDescriptionWrapper>();
List<String> fieldSet = new List<String>();
Set<String> fieldNameSet = new Set<String>(fieldNamesStr.split(','));
if(Schema.getGlobalDescribe().containsKey(ObjectName) ) {
sObject sObj = Schema.getGlobalDescribe().get(ObjectName).newSObject() ;
//get all the labels for Opportunity fields and put them in a map, keyed to the field api name
Map<String, Schema.SObjectField> fieldMap = Schema.getGlobalDescribe().get(ObjectName).getDescribe().fields.getMap();
Map<Schema.SObjectField,String> fieldToAPIName = new Map<Schema.SObjectField,String>();
Map<String, String> apiNameToLabel = new Map<String, String>();
for(String fieldName : fieldNameSet){
if(fieldMap.containsKey(fieldName)) {
fieldSet.add(fieldName);
labelList.add(new LabelDescriptionWrapper(fieldMap.get(fieldName).getDescribe().getLabel(), fieldName, fieldMap.get(fieldName).getDescribe().getType().name().toLowerCase(), true,inlineEdit, null ));
}
}
//add action
if(enableColAction) {
List<Actions> actionList = new List<Actions>();
actionList.add(new Actions('Edit','Edit'));
actionList.add(new Actions('View','View'));
actionList.add(new Actions('Delete','Delete'));
TypeAttributes tAttribute = new TypeAttributes(actionList);
labelList.add(new LabelDescriptionWrapper('Actions', 'Actions', 'action', false, false, tAttribute ));
}
//call method to query
List<sObject> sObjectRecords = getsObjectRecords(ObjectName, fieldSet, 50, '', Orderby, OrderDir);
dtw.ldwList = labelList;
dtw.sobList = sObjectRecords;
dtw.fieldsList = fieldSet;
dtw.totalCount = Database.countQuery('SELECT count() FROM '+ObjectName);
}
return dtw;
}
@AuraEnabled
public static List<sObject> getsObjectRecords(String ObjectName, List<String> fieldNameSet, Integer LimitSize, String recId, String Orderby, String OrderDir) {
OrderDir = String.isBlank(OrderDir) ? 'asc' : OrderDir;
String query = 'SELECT '+String.join(fieldNameSet, ',')+' FROM '+ObjectName;
if(String.isNotBlank(recId)) {
recId = String.valueOf(recId);
query += ' WHERE ID >: recId ';
}
query += ' ORDER BY '+Orderby+' '+OrderDir+' NULLS LAST';
if(LimitSize != null && Integer.valueOf(LimitSize) > 0) {
LimitSize = Integer.valueOf(LimitSize);
query += ' Limit '+LimitSize;
}
return Database.query(query);
}
@AuraEnabled
public static sObject deleteSObject(sObject sob) {
delete sob;
return sob;
}
@AuraEnabled
public static void updateRecords(List<sObject> sobList, String updateObjStr, String objectName) {
//
System.debug(updateObjStr);
System.debug(sobList);
schema.SObjectType sobjType = Schema.getGlobalDescribe().get(ObjectName);
Map<String, Schema.sObjectField> sObjectFields = sobjType.getDescribe().fields.getMap();
List<sObject> updateList = new List<sObject>();
List<Object> obList = (List<object>) json.deserializeUntyped(updateObjStr);
for(object ob : obList) {
Map<String, object> obmap = (Map<String, object>)ob;
String rowKey = (String)obmap.get('id');
Integer rowKeyInt = Integer.ValueOf(rowKey.removeStart('row-'));
sobject sObj = sobList[rowKeyInt];
for(string fieldName : obmap.keySet()) {
if(fieldName != 'id') {
Object value = obmap.get(fieldName);
Schema.DisplayType valueType = sObjectFields.get(fieldName).getDescribe().getType();
if (value instanceof String && valueType != Schema.DisplayType.String)
{
String svalue = (String)value;
if (valueType == Schema.DisplayType.Date)
sObj.put(fieldName, Date.valueOf(svalue));
else if(valueType == Schema.DisplayType.DateTime) {
try{
System.debug( (DateTime)value);
}catch(exception ex) {
system.debug(ex.getmessage());
}
String d1 = svalue;
list<String> d2 = d1.split('-');
list<integer> timeComponent = new list<integer>();
timeComponent.add(Integer.valueOf(d2[0]));
timeComponent.add(Integer.valueOf(d2[1]));
timeComponent.add(Integer.valueOf(d2[2].left(2)));
String t = d2[2].substringBetween('T','.');
list<String> time1 = t.split(':');
timeComponent.add(Integer.valueOf(time1[0]));
timeComponent.add(Integer.valueOf(time1[1]));
timeComponent.add(Integer.valueOf(time1[2]));
Datetime dt = Datetime.newInstance(timeComponent[0],timeComponent[1],timeComponent[2],timeComponent[3],timeComponent[4],timeComponent[5]);
sObj.put(fieldName, dt);
}
//
else if (valueType == Schema.DisplayType.Percent || valueType == Schema.DisplayType.Currency)
sObj.put(fieldName, svalue == '' ? null : Decimal.valueOf(svalue));
else if (valueType == Schema.DisplayType.Double)
sObj.put(fieldName, svalue == '' ? null : Double.valueOf(svalue));
else if (valueType == Schema.DisplayType.Integer)
sObj.put(fieldName, Integer.valueOf(svalue));
else if (valueType == Schema.DisplayType.Base64)
sObj.put(fieldName, Blob.valueOf(svalue));
else
sObj.put(fieldName, svalue);
}
else
sObj.put(fieldName, value);
}
}
updateList.add(sObj);
}
update updateList;
//return sobList;
}
public class DataTableWrapper {
@AuraEnabled
public List<LabelDescriptionWrapper> ldwList;
@AuraEnabled
public List<sObject> sobList;
@AuraEnabled
public List<String> fieldsList;
@AuraEnabled
public Integer totalCount;
}
public class LabelDescriptionWrapper {
@AuraEnabled
public String label;
@AuraEnabled
public String fieldName;
@AuraEnabled
public String type;
@AuraEnabled
public boolean sortable;
@AuraEnabled
public boolean editable;
@AuraEnabled
public TypeAttributes typeAttributes;
public LabelDescriptionWrapper(String labelTemp, String fieldNameTemp, String typeTemp, boolean sortableTemp, boolean editableTemp,TypeAttributes typeAttributesTemp) {
label = labelTemp;
fieldName = fieldNameTemp;
type = typeTemp;
sortable = sortableTemp;
editable = editableTemp;
typeAttributes = typeAttributesTemp;
}
}
public class TypeAttributes {
@AuraEnabled
public List<Actions> rowActions;
public typeAttributes(List<Actions> rowActionsTemp) {
rowActions = rowActionsTemp;
}
}
public class Actions {
@AuraEnabled
public String label;
@AuraEnabled
public String name;
public Actions(String labelTemp, String nameTemp) {
label = labelTemp;
name = nameTemp;
}
}
}
DataTableLazyLoading.cmp
So this is our main component. Here we have multiple attributes therefore we can easily control flow using passing the attributes from parent component. So here we can decide Object, fields, Initail row we want to display, enable lazy loading, show the row number and many more.
<aura:component controller="DataTableController" implements="force:appHostable,flexipage:availableForAllPageTypes,flexipage:availableForRecordHome,force:hasRecordId,forceCommunity:availableForAllPageTypes,force:lightningQuickAction" access="global" >
<!-- attributes -->
<aura:attribute name="objectName" type="String" default="Account"/>
<aura:attribute name="fieldsString" type="String" default="Name,Phone,Email,Website"/>
<aura:attribute name="fieldsList" type="List" default="[]"/>
<aura:attribute name="columns" type="List" default="[]"/>
<aura:attribute name="data" type="List" default="[]"/>
<aura:attribute name="selectedData" type="List" default="[]"/>
<aura:attribute name="keyField" type="String" default="id"/>
<aura:attribute name="initialRows" type="Integer" default="5"/>
<aura:attribute name="selectedRowsCount" type="Integer" default="0"/>
<aura:attribute name="enableInfiniteLoading" type="Boolean" default="true"/>
<aura:attribute name="rowsToLoad" type="Integer" default="50"/>
<aura:attribute name="totalNumberOfRows" type="Integer" default="3000"/>
<aura:attribute name="loadMoreStatus" type="String" default=""/>
<aura:attribute name="sortedBy" type="String" default="Id"/>
<aura:attribute name="sortedDirection" type="String" default="ASC"/>
<aura:attribute name="inlineEdit" type="boolean" default="true"/>
<aura:attribute name="enableColAction" type="boolean" default="true"/>
<aura:attribute name="showRowNumber" type="boolean" default="true"/>
<aura:attribute name="hideCheckboxColumn" type="boolean" default="false"/>
<aura:attribute name="maxRowSelection" type="Integer" default="50"/>
<aura:attribute name="tableHeight" type="Integer" default="450"/>
<aura:attribute name="errors" type="Object" default="[]"/>
<aura:attribute name="pageReference" type="Object"/>
<lightning:navigation aura:id="navService"/>
<lightning:notificationsLibrary aura:id="notifLib"/>
<!-- handlers-->
<aura:handler name="init" value="{! this }" action="{! c.init }"/>
<div class="slds-is-relative">
<!-- the container element determine the height of the datatable -->
<div style="{!'height:'+v.tableHeight+'px'}">
<lightning:datatable aura:id="dataTable"
columns="{! v.columns }"
data="{! v.data }"
keyField="{! v.keyField }"
showRowNumberColumn="{! v.showRowNumber }"
sortable = "true"
editable = "true"
onsort = "{! c.updateColumnSorting }"
sortedBy="{!v.sortedBy}"
sortedDirection="{!v.sortedDirection}"
onrowselection="{! c.updateSelectedRow }"
maxRowSelection="{! v.maxRowSelection }"
enableInfiniteLoading="{! v.enableInfiniteLoading }"
onloadmore="{! c.loadMoreData }"
onrowaction="{! c.handleRowAction }"
errors="{! v.errors }"
draftValues="{! v.draftValues }"
onsave="{! c.handleSaveEdition }"
/><!-- hideCheckboxColumn = "{! c.hideCheckboxColumn }" -->
</div>
{! v.loadMoreStatus }
</div>
</aura:component>
DataTableLazyLoadingController.js
In the component controller we have multiple methods. Firstly, the init method in which we will initially load the records. Secondly, updateSelectedRow which we will call when we want to update selected rows. Thirdly methods for reset everything, Update the column sort direction and handle row level action. and Finally save all the record after inline edit.
({
init: function (cmp, event, helper) {
console.log( cmp.get("v.data").length );
helper.initData(cmp, event, cmp.get("v.data").length);
},
updateSelectedRow: function (cmp, event) {
var selectedRows = event.getParam('selectedRows');
cmp.set('v.selectedRowsCount', selectedRows.length);
cmp.set('v.selectedData', selectedRows);
console.log( cmp.get('v.selectedData') );
},
resetRows: function (cmp, event, helper) {
cmp.set('v.data', []);
helper.initData(cmp, event, cmp.get("v.data").length);
},
loadMoreData: function (cmp, event, helper) {
var rowsToLoad = cmp.get('v.rowsToLoad');
event.getSource().set("v.isLoading", true);
cmp.set('v.loadMoreStatus', 'Loading');
helper.fetchData(cmp, event, cmp.get("v.data").length);
},
updateColumnSorting: function (cmp, event, helper) {
var fieldName = event.getParam('fieldName');
var sortDirection = event.getParam('sortDirection');
// assign the latest attribute with the sorted column fieldName and sorted direction
cmp.set("v.sortedBy", fieldName);
cmp.set("v.sortedDirection", sortDirection);
cmp.set('v.data', []);
event.getSource().set("v.isLoading", true);
cmp.set('v.loadMoreStatus', 'Loading');
helper.initData(cmp, event, cmp.get("v.data").length);
},
handleRowAction: function (cmp, event, helper) {
var action = event.getParam('action');
var row = event.getParam('row');
switch (action.name) {
case 'Edit':
helper.editRecord(cmp, row);
break;
case 'View':
helper.viewRecord(cmp, row);
break;
case 'Delete':
helper.deleteRecord(cmp, row);
break;
default:
helper.viewRecord(cmp,row);
break;
}
},
handleSaveEdition: function (cmp, event, helper) {
var draftValues = event.getParam('draftValues');
var data = cmp.get('v.data');
helper.saveRecords(cmp, event, helper, data, draftValues);
},
})
DataTableLazyLoadingHelper.js
Finally in the helper we are doing actual call to apex methods and doing the updates. In addition we have also defined logic of client side sorting here.
({
initData: function (cmp, event, numberOfRecords) {
var action = cmp.get("c.initRecords");
action.setParams({
ObjectName : cmp.get("v.objectName"),
fieldNamesStr : cmp.get("v.fieldsString"),
Orderby : cmp.get("v.sortedBy"),
OrderDir : cmp.get("v.sortedDirection"),
inlineEdit: cmp.get("v.inlineEdit"),
enableColAction: cmp.get("v.enableColAction")
});
action.setCallback(this, function(response) {
var state = response.getState();
if (state === "SUCCESS") {
console.log(response.getReturnValue().ldwList);
cmp.set("v.columns", response.getReturnValue().ldwList);
cmp.set("v.data", response.getReturnValue().sobList);
cmp.set("v.fieldsList", response.getReturnValue().fieldsList);
cmp.set("v.totalNumberOfRows", response.getReturnValue().totalCount);
cmp.set('v.loadMoreStatus', '');
event.getSource().set("v.isLoading", false);
}
else if (state === "ERROR") {
var errors = response.getError();
if (errors) {
if (errors[0] && errors[0].message) {
console.log("Error message: " +
errors[0].message);
}
} else {
console.log("Unknown error");
}
}
});
$A.enqueueAction(action);
},
fetchData: function (cmp, event, numberOfRecords) {
//var dataPromise;
var data = cmp.get("v.data");
var dataSize = cmp.get("v.data").length;
var lastId = data[dataSize - 1].Id;
var action = cmp.get("c.getsObjectRecords");
action.setParams({
ObjectName : cmp.get("v.objectName"),
fieldNameSet : cmp.get("v.fieldsList"),
LimitSize : 50,
recId : lastId,
Orderby : cmp.get("v.sortedBy"),
OrderDir : cmp.set("v.sortedDirection")
});
action.setCallback(this, function(response) {
var state = response.getState();
if (state === "SUCCESS") {
if (cmp.get('v.data').length >= cmp.get('v.totalNumberOfRows')) {
cmp.set('v.enableInfiniteLoading', false);
cmp.set('v.loadMoreStatus', 'No more data to load');
} else {
var currentData = cmp.get('v.data');
var newData = currentData.concat(response.getReturnValue());
cmp.set('v.data', newData);
cmp.set('v.loadMoreStatus', '');
}
event.getSource().set("v.isLoading", false);
}
else if (state === "ERROR") {
var errors = response.getError();
if (errors) {
if (errors[0] && errors[0].message) {
console.log("Error message: " +
errors[0].message);
}
} else {
console.log("Unknown error");
}
}
});
$A.enqueueAction(action);
},
sortData: function (cmp, fieldName, sortDirection) {
var data = cmp.get("v.data");
var reverse = sortDirection !== 'asc';
//sorts the rows based on the column header that's clicked
data.sort(this.sortBy(fieldName, reverse))
cmp.set("v.data", data);
},
sortBy: function (field, reverse, primer) {
var key = primer ?
function(x) {return primer(x[field])} :
function(x) {return x[field]};
//checks if the two rows should switch places
reverse = !reverse ? 1 : -1;
return function (a, b) {
return a = key(a), b = key(b), reverse * ((a > b) - (b > a));
}
},
editRecord: function (cmp, row) {
var navService = cmp.find("navService");
var pageReference = {
type: 'standard__recordPage',
attributes: {
"recordId": row.Id,
"objectApiName": cmp.get("v.objectName"),
"actionName": "edit"
}
}
navService.navigate(pageReference);
},
viewRecord : function(cmp, row) {
var navService = cmp.find("navService");
console.log(row);
var pageReference = {
type: 'standard__recordPage',
attributes: {
"recordId": row.Id,
"objectApiName": cmp.get("v.objectName"),
"actionName": "view"
}
}
navService.navigate(pageReference);
},
deleteRecord : function(cmp, row) {
var action = cmp.get("c.deleteSObject");
action.setParams({
"sob":row
});
action.setCallback(this, function(response) {
var state = response.getState();
if (state === "SUCCESS") {
var rows = cmp.get('v.data');
var rowIndex = rows.indexOf(row);
rows.splice(rowIndex, 1);
cmp.set('v.data', rows);
/* var toastEvent = $A.get("e.force:showToast");
toastEvent.setParams({
"title": "Success!",
"message": "Record deleted.",
"type": "success"
});
toastEvent.fire();
*/
cmp.find('notifLib').showNotice({
"variant": "info",
"header": "Success!",
"message": "Record deleted.",
closeCallback: function() {
}
});
}
else if (state === "ERROR") {
// handle error
var errorMsg = '';
var errors = response.getError();
if (errors) {
if (errors[0] && errors[0].message) {
errorMsg = errors[0].message;
}
if (errors[0] && errors[0].pageErrors && errors[0].pageErrors[0].message) {
errorMsg += errors[0].pageErrors[0].message;
}
}
cmp.find('notifLib').showNotice({
"variant": "error",
"header": "Something has gone wrong!",
"message": errorMsg,
closeCallback: function() {
}
});
/*
var errors = response.getError();
var toastEvent = $A.get("e.force:showToast");
toastEvent.setParams({
"title": "error",
"message": errorMsg,
"type": "error"
});
toastEvent.fire();*/
}
});
$A.enqueueAction(action);
},
saveRecords : function(cmp, event, helper, data, draftValues) {
var action = cmp.get("c.updateRecords");
var draftValuesStr = JSON.stringify(draftValues);
action.setParams({
"sobList":data,
"updateObjStr" : draftValuesStr,
"objectName" : cmp.get("v.objectName")
});
action.setCallback(this, function(response) {
var state = response.getState();
if (state === "SUCCESS") {
cmp.set('v.errors', []);
cmp.set('v.draftValues', []);
helper.initData(cmp, event, 50);
}
else if (state === "ERROR") {
// handle error
var errorMsg = '';
var errors = response.getError();
if (errors) {
if (errors[0] && errors[0].message) {
errorMsg = errors[0].message;
}
if (errors[0] && errors[0].pageErrors && errors[0].pageErrors[0].message) {
errorMsg += errors[0].pageErrors[0].message;
}
}
cmp.find('notifLib').showNotice({
"variant": "error",
"header": "Something has gone wrong!",
"message": errorMsg,
closeCallback: function() {
}
});
cmp.set('v.errors', errorMsg);
}
});
$A.enqueueAction(action);
}
});
So you can easily use this component in your project without need to define everything again and again.
Like my facebook page for more similar updates.
Did you like the post or want to add anything, let me know in comments section. Happy Programming 🙂
Hi Can we get the edited row number?
We get the edited rw in controller Using them you can easily find the row number.
help me test class
What issue you are facing in test class?
Hi, Tushar. Is there a version of this in LWC?
Yes, here it is https://newstechnologystuff.com/2019/03/24/lightning-web-components-datatable-with-lazy-loading-inline-edit-and-dynamic-row-action/
Hey Tushar! Can you help me understand how Sorting and Lazy Loading is working together?
Let’s say we are using Account records and this component.
Initially 50 accounts are displayed sorted by ID ASC.
Suppose “Type” column is sorted from UI now, so the component will send fieldname and direction, and LastRecordID as blank from initRecords().
In return, it displays 50 records sorted by “Type”.
Now, when we scroll down and loadMoreData is called, it calls Apex with “Type” as sort column and last RecordID as well.
This doesn’t return the expected data. as lastRecordID and sort causes some records to be missed.
Hi Tushar,
How can I adapt this table to work in a related list? example; show all related Contacts to an Account
Thank you!
Hi Rafa,
Pass the parameter details (eg: object name and fields) and it will work for you.
Thanks
Hi Tushar,
Thank you for such a thorough example. How would we pass editable = true and draft values to the entire table so that all cells can be edited at once?
Thanks,
Emily