Header Ads Widget

Responsive Advertisement

Overcoming Salesforce Related List Limitations: A Custom Solution with Search, Sort, and Inline Editing

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> columns = new List>();
            for (String field : fields) {
                Schema.DescribeFieldResult fieldDescribe = fieldMap.get(field.trim()).getDescribe();

                Map column = new Map();
                column.put('label', fieldDescribe.getLabel());
                column.put('fieldName', fieldDescribe.getName());
                column.put('sortable', true);

                if (!fieldDescribe.isUpdateable()) {
                    column.put('editable', false);
                } else {
                    column.put('editable', true);
                }

                // Determine the column type based on field metadata
                if (fieldDescribe.getType() == Schema.DisplayType.BOOLEAN) {
                    column.put('type', 'boolean');
                } else if (fieldDescribe.getType() == Schema.DisplayType.CURRENCY) {
                    column.put('type', 'currency');
                } else if (fieldDescribe.getType() == Schema.DisplayType.DATE) {
                    column.put('type', 'date');
                } else if (fieldDescribe.getType() == Schema.DisplayType.DATETIME) {
                    column.put('type', 'datetime');
                } else if (fieldDescribe.getType() == Schema.DisplayType.DOUBLE) {
                    column.put('type', 'number');
                } else if (fieldDescribe.getType() == Schema.DisplayType.PERCENT || fieldDescribe.getCalculatedFormula() != null) {
                    column.put('type', 'number');
                    column.put('cellAttributes', new Map{
                        'alignment' => 'left' // Align percentages to the right
                    });
                } 
                else if (fieldDescribe.getType() == Schema.DisplayType.EMAIL) {
                    column.put('type', 'email');
                } else if (fieldDescribe.getType() == Schema.DisplayType.URL) {
                    column.put('type', 'url');
                } else {
                    column.put('type', 'text');
                }

                if (fieldDescribe.getType() == Schema.DisplayType.STRING || 
                    fieldDescribe.getType() == Schema.DisplayType.PICKLIST || 
                    fieldDescribe.getCalculatedFormula() != null && fieldDescribe.getType() == Schema.DisplayType.STRING) {
                    column.put('type', 'text'); // Use 'text' for formula fields of type text
                }

                columns.add(column);
            }

            result.put('records', wrappedRecords);
            result.put('columns', columns);
        }catch(Exception e){
            throw new AuraHandledException('Error retrieving records: ' + e.getMessage());
        }
        return result;
    }

    @AuraEnabled
    public static void saveRecords(List updatedRecords) {
        try {
            update updatedRecords;
        } catch (DmlException e) {
            // Parse DMLException to extract meaningful error messages
            String errorMessage = 'Error Saving Records: ';
            for (Integer i = 0; i < e.getNumDml(); i++) {
                if (e.getDmlMessage(i) != null) {
                    errorMessage += e.getDmlMessage(i) + '; ';
                }
            }
            throw new AuraHandledException(errorMessage.trim());
        } catch (Exception e) {
            throw new AuraHandledException('Unexpected error saving records: ' + e.getMessage());
        }
    }

    @AuraEnabled(cacheable=true)
    public static Map getObjectSummary(String parentRecordId, String childObjectName, String relationshipFieldName) {
        Map result = new Map();
        try {
            // Build the dynamic SOQL query
            String query = 'SELECT COUNT(Id) recordCount, MAX(LastModifiedDate) lastUpdated ' +
                           'FROM ' + childObjectName + 
                           ' WHERE ' + relationshipFieldName + ' = :parentRecordId';

            // Execute the query
            List queryResult = Database.query(query);

            // Extract results
            Integer recordCount = (queryResult.isEmpty() ? 0 : (Integer)queryResult[0].get('recordCount'));
            Datetime lastUpdated = (queryResult.isEmpty() ? null : (Datetime)queryResult[0].get('lastUpdated'));

            // Populate results into the map
            result.put('recordCount', recordCount);
            result.put('lastUpdated', lastUpdated != null ? lastUpdated.format() : 'No Updates');

        } catch (Exception e) {
            throw new AuraHandledException('Error fetching related list summary: ' + e.getMessage());
        }
        return result;
    }
}

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!

Post a Comment

0 Comments