Building a Dynamic Related List with Search, Sort and Inline Editing in Salesforce
Salesforce’s standard related lists can be limiting for developers and admins seeking advanced functionality like search, sorting, and inline editing. In this blog, I address these challenges and showcase a custom-built solution designed to overcome these constraints. Leveraging Lightning Web Components and Apex, this approach delivers a dynamic, user-friendly related list that enhances productivity and data management. Follow along to learn how you can implement this solution to empower your users and elevate your Salesforce experience.
In this blog, we’ll walk through its architecture, key features, and a detailed implementation. The blog includes all necessary code to help you integrate and customize the solution effectively.
Key Features of the Dynamic Related List
- Dynamic Adaptability: Works with any Salesforce object, making it highly reusable.
- Search Functionality: Quickly locate records with an integrated search bar.
- Inline Editing: Edit records directly within the table to save time and improve data accuracy.
- Customizable Columns: Dynamically configure columns based on the selected fields.
- Sorting Configure default field by which table will be sorted, later table can be sorted by any column
Solution Architecture
This solution is composed of the following:
- Lightning Web Component (LWC): Manages the UI and interactions.
- Apex Controller: Handles server-side operations like querying data and saving updates.
- CSS: Ensures a clean, responsive design consistent with Salesforce styling.
- XML Metadata: Configures the component for deployment and customization.
Code Implementation
LWC Template (HTML)
The template defines the UI, including the search bar and data table. Below is the code:
<template>
<lightning-card>
<div class="card">
<div class="icon">
<img src={objectIcon} alt={objectLabel} />
</div>
<div class="details">
<h2>{objectLabel}</h2>
<p>{recordCount} items • Updated {lastUpdated}</p>
</div>
<div class="slds-col slds-size_1-of-3">
<lightning-input
type="search"
label="Search"
variant="label-hidden"
placeholder="Search records"
onchange={handleSearch}>
</lightning-input>
</div>
</div>
<div class="table-container" style="height: 400px; overflow-y: auto;" onscroll={handleScroll}>
<template if:true={tableColumns}>
<lightning-datatable
key-field="Id"
data={filteredTableData}
columns={tableColumns}
draft-values={draftValues}
onsave={handleSave}
onsort={handleSort}
hide-checkbox-column
sorted-by={sortedBy}
sorted-direction={sortedDirection}
default-sort-direction={defaultSortDirection}
is-loading={isLoading}>
</lightning-datatable>
</template>
<template if:false={tableColumns}>
<lightning-spinner alternative-text="Loading..."></lightning-spinner>
</template>
</div>
</lightning-card>
</template>
LWC Styling
Styling is kept inline with standard Related List, custom CSS has been used to achieve that
.table-container{
border: 1px solid #ddd;
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}
.card {
display: flex;
align-items: center;
padding: 10px;
border: 1px solid #ddd;
border-bottom: 0;
border-radius: 5px;
background-color: #f3f3f3;
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
}
.icon img {
width: 36px;
height: 36px;
margin-right: 10px;
}
.details {
flex: 1;
}
h2 {
margin: 0;
font-size: 16px;
font-weight: bold;
}
p {
margin: 0;
font-size: 14px;
color: #666;
}
LWC JavaScript
The JavaScript file powers the component's logic and interactions. Below is the complete code:
import { LightningElement, api, wire, track } from 'lwc';
import getRelatedRecords from '@salesforce/apex/DynamicRelatedListController.getRelatedRecords';
import saveRecords from '@salesforce/apex/DynamicRelatedListController.saveRecords';
import { refreshApex } from '@salesforce/apex';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import getObjectSummary from '@salesforce/apex/DynamicRelatedListController.getObjectSummary';
import { getObjectInfo } from 'lightning/uiObjectInfoApi';
export default class DynamicRelatedList extends LightningElement {
@api recordId; // Record ID from Lightning Record Page
@api relatedObjectApiName; // Related object API name
@api relationshipFieldName; // Relationship field API name
@api displayFields; // Comma-separated fields to display
@api relatedListName = 'Related List'; // Configurable related list name
recordCount = 0;
lastUpdated = 'No Updates';
objectLabel;
objectIcon;
@track tableData = [];
@track filteredTableData = [];
@track tableColumns = [];
@track draftValues = [];
@track isLoading = true;
// Sorting variables
@api sortedBy;
@track sortedDirection = 'desc';
@track defaultSortDirection = 'desc';
wiredDataResult; // Store the wired data for refresh
// Fetch related records dynamically using Apex
@wire(getRelatedRecords, {
parentObjectId: '$recordId',
relatedObjectApiName: '$relatedObjectApiName',
relationshipFieldName: '$relationshipFieldName',
displayFields: '$displayFields',
defaultSortField: '$sortedBy'
})
wiredData(result) {
this.wiredDataResult = result;
if (result.data) {
this.tableData = result.data.records.map(wrapper => ({
...wrapper.record, // Extract SObject fields
index: wrapper.index, // Include the index
}));
this.filteredTableData = [...this.tableData];
this.filteredTableData.sort((a, b) => a.index - b.index);
this.tableColumns = result.data.columns;
this.isLoading = false;
} else if (result.error) {
this.showToast('Error', result.error.body.message, 'error');
this.isLoading = false;
}
}
formatDataForDisplay(records) {
return records.map(record => {
const formattedRecord = { ...record };
this.tableColumns.forEach(column => {
if (column.fieldName === 'Base_Discount__c' && formattedRecord[column.fieldName] != null) {
formattedRecord[column.fieldName] = `${formattedRecord[column.fieldName]}%`; // Append '%'
}
});
return formattedRecord;
});
}
// Handle inline save
handleSave(event) {
const updatedFields = event.detail.draftValues.map(record => {
const updatedRecord = { ...record };
this.tableColumns.forEach(column => {
if (column.type === 'number' && updatedRecord[column.fieldName] != null) {
updatedRecord[column.fieldName] = parseFloat(updatedRecord[column.fieldName]); // Convert UI percentage to raw number
}
if (column.fieldName === 'Base_Discount__c' && updatedRecord[column.fieldName] != null) {
// Remove '%' and convert to a number
updatedRecord[column.fieldName] = parseFloat(updatedRecord[column.fieldName].replace('%', '').trim());
}
});
return updatedRecord;
});
saveRecords({ updatedRecords: updatedFields })
.then(() => {
this.showToast('Success', 'Records updated successfully', 'success');
this.draftValues = [];
return refreshApex(this.wiredDataResult); // Refresh data after save
})
.then(() => {
/*if (this.sortedBy) {
this.handleSort({
detail: {
fieldName: this.sortedBy,
sortDirection: this.sortedDirection,
},
});
}*/
//this.filteredTableData.sort((a, b) => a.index - b.index);
this.filteredTableData = this.formatDataForDisplay(this.tableData).sort((a, b) => a.index - b.index);
})
.catch(error => {
this.handleError(error);
});
}
// Handle sorting
handleSort(event) {
const { fieldName: sortedBy, sortDirection } = event.detail;
this.sortedBy = sortedBy;
this.sortedDirection = sortDirection;
const sortedData = [...this.filteredTableData];
sortedData.sort((a, b) => {
// Get field values
let valueA = a[sortedBy] !== undefined && a[sortedBy] !== null ? a[sortedBy] : 0;
let valueB = b[sortedBy] !== undefined && b[sortedBy] !== null ? b[sortedBy] : 0;
// Ensure numeric parsing for numbers
const columnType = this.tableColumns.find(col => col.fieldName === sortedBy)?.type;
if (columnType === 'number') {
valueA = parseFloat(valueA) || 0; // Default to 0 if NaN
valueB = parseFloat(valueB) || 0; // Default to 0 if NaN
}
// Debug logging
console.log(`Sorting ${sortedBy}:`, { valueA, valueB });
// Sort based on type
if (typeof valueA === 'number' && typeof valueB === 'number') {
return sortDirection === 'asc' ? valueA - valueB : valueB - valueA;
} else if (valueA instanceof Date && valueB instanceof Date) {
return sortDirection === 'asc'
? valueA.getTime() - valueB.getTime()
: valueB.getTime() - valueA.getTime();
} else {
return sortDirection === 'asc'
? String(valueA).localeCompare(String(valueB))
: String(valueB).localeCompare(String(valueA));
}
});
this.filteredTableData = sortedData;
}
// Handle search (optional if required)
handleSearch(event) {
const searchTerm = event.target.value.toLowerCase();
this.filteredTableData = this.tableData.filter(record =>
Object.values(record).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
// Show toast notifications
showToast(title, message, variant) {
this.dispatchEvent(
new ShowToastEvent({
title,
message,
variant,
})
);
}
// Handle errors in save operations
handleError(error) {
let message = 'An error occurred';
console.error('Error details:', error);
// Parse pageErrors for meaningful messages
if (error.body) {
if (Array.isArray(error.body.pageErrors) && error.body.pageErrors.length > 0) {
message = error.body.pageErrors.map(err => err.message).join(', ');
} else if (error.body.fieldErrors && Object.keys(error.body.fieldErrors).length > 0) {
message = Object.values(error.body.fieldErrors)
.flat()
.map(err => err.message)
.join(', ');
} else if (error.body.message) {
message = error.body.message;
}
}
this.showToast('Error', message, 'error');
}
@wire(getObjectInfo, { objectApiName: '$relatedObjectApiName' })
handleObjectInfo({ data, error }) {
if (data) {
console.log('data..', data);
this.objectLabel = data.label;
this.objectIcon = data.themeInfo.iconUrl; // URL for the standard object icon
} else if (error) {
console.error(error);
}
}
@wire(getObjectSummary, {
parentRecordId: '$recordId',
childObjectName: '$relatedObjectApiName',
relationshipFieldName: '$relationshipFieldName'
})
handleObjectSummary({ data, error }) {
if (data) {
console.log('data..', data);
this.recordCount = data.recordCount || 0;
this.lastUpdated = data.lastUpdated ? new Date(data.lastUpdated).toLocaleString() : 'No Updates';
} else if (error) {
console.error(error);
}
}
get displayRecordCount() {
return this.recordCount > 10 ? '10+' : this.recordCount;
}
}
XML Metadata
The metadata file defines the component's properties for deployment. Below is the full XML code:
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>62.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__RecordPage">
<property name="relatedObjectApiName" type="String" label="Related Object API Name" />
<property name="sortedBy" type="String" label="Default Sorting Field API Name" />
<property name="relationshipFieldName" type="String" label="Relationship Field API Name" />
<property name="displayFields" type="String" label="Fields to Display (comma-separated)" />
<property name="relatedListName" type="String" label="Related List Name" default="Related List" />
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
Apex Controller
The Apex class provides server-side logic for fetching and updating data. Below is the complete code:
public with sharing class DynamicRelatedListController {
// Wrapper class to include record and index
public class RecordWrapper {
@AuraEnabled public Integer index { get; set; }
@AuraEnabled public SObject record { get; set; }
public RecordWrapper(Integer index, SObject record) {
this.index = index;
this.record = record;
}
}
@AuraEnabled(cacheable=true)
public static Map getRelatedRecords(String parentObjectId, String relatedObjectApiName, String relationshipFieldName, String displayFields, String defaultSortField) {
Map result = new Map();
try{
// Query related records dynamically
String query = 'SELECT Id, ' + displayFields + ' FROM ' + relatedObjectApiName +
' WHERE ' + relationshipFieldName + ' = :parentObjectId ORDER By ' + defaultSortField + ' DESC NULLS LAST';
List relatedRecords = Database.query(query);
List wrappedRecords = new List();
for (Integer i = 0; i < relatedRecords.size(); i++) {
wrappedRecords.add(new RecordWrapper(i, relatedRecords[i]));
}
// Generate columns dynamically based on field metadata
List fields = displayFields.split(',');
Map fieldMap = Schema.getGlobalDescribe().get(relatedObjectApiName).getDescribe().fields.getMap();
List
Example:
Conclusion
This detailed guide equips you to implement a dynamic related list with essential features like inline editing and search. Customize it further to meet your organization's requirements!
0 Comments