Monday, November 9, 2020

Build Configurable Dynamic Table from Field Set using Lightning Web Component

 

Motivation behind this


I have been looking for building configurable Related list component using Field Set and Lightning Web Component. Earlier I have written blog on  Describe Objects and Retrieve Records using Salesforce Lightning Web Components flavored with Dynamic Datatable.

Now, I want to move it one step further using field set. This approach will help many developers to quickly develop and leverage this concepts.

Here is a justification why it is needed to build.

Use Case


Business has requirement to view the Record detail page as well as its Related list. User has edit and delete permissions to the related list's objects. But business wants that user will be not see any options to edit or delete records from the related list.

For example, Case related list is available under Account or Contact record. User has a permission to edit or delete on Case object but those records only be available for view purpose. User will not able to see Edit/Delete option.

Only option is to built this component as read-only viewing purpose.

This requirement is applicable for similar use cases like this, so it will be better to create a configurable component using Field Set.

Fields from the Field Set will be displayed as columns and records based on those fields will be displayed as rows.

Possible End Result


After building the use case, it will look like this:



Solution Approach


Refer this video for three step solutions:


For an example purpose, I have tried to build Case object as Related List.


First Step

Create a Field Set on Case Object and add necessary fields on the layout. For example name is CaseRelatedListFS


Second Step

Build a LWC Component which will take following configurable attributes:
  • Related Object API Name - Here it is Case
  • Field Set Name - The name of field set defined on the Object whose fields will be displayed as table columns. Here it is CaseRelatedListFS
  • Reference API Name - It is the field based on which query to be fired. Here, it is ContactId, as Case related list will be placed on Contact Record Page.
  • First Column As RecordHyperlink - It will take Yes/No. For example, if we define CaseNumber as first column and if we choose Yes option then Case Number will be shown as hyperlink and clicking on that link, it will be navigated to Case Detail page. If we choose No then, it will be same for other columns.
Let's see this lwcFieldSetComponent.js-meta.xml where those parameters can be configured as targetConfig property.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>50.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordPage,lightning__AppPage,lightning__HomePage">
            <property name="SFDCobjectApiName" label="Related Object API Name" type="String" default=""/>
            <property name="fieldSetName" label="Field Set Name" type="String" default=""/>
            <property name="criteriaFieldAPIName" label="Reference FieldAPIName" type="String" default=""
                            description="The field on which query to be performed. 
                            e.g. it can be AccountId from which Case records will be fetched."/>
            <property name="firstColumnAsRecordHyperLink" label="First Column As RecordHyperLink" 
                            type="String" datasource="Yes,No" default="Yes"/>                        
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Approach has been taken following way:
  • Create a LWC component adding a lightning-datatable which will be populated on load.
  • In the connectedCallback method of js it will fetch fields and records from the database
  • Finally display those in the datatable.
lwcFieldSetComponent.html


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<template>    
    <div class="c-container">
        <div class="slds-card">
            <div class="slds-media__body">
                <h2 class="slds-card__header-title">
                    <span>{lblobjectName} Records ({recordCount})</span>
                </h2>
            </div>
            <div class="slds-card__body">
                <lightning-datatable 
                    key-field="Id"
                    data={tableData}
                    columns={columns}
                    min-column-width=200>
                </lightning-datatable> 
            </div>                           
        </div> 
    </div>    
</template>

lwcFieldSetComponent.js

Few important points to refer here:


  • Never define an attribute as objectApiName as it is reserved, that's why I have used SFDCobjectApiName, otherwise, it will always take current object.

  • When we pull entries from a Map which has been stored in Apex class, the index is inversed. For example, I have first stored FIELD_LIST and then RECORD_LIST in the Map. Now, the index of keys will be 1 for FIELD_LIST and 0 from RECORD_LIST.

  • Extra coding to handle hyperlink record navigation in the first column.


Rest of the comments in the code is self-explanatory.


  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
/*
 *   Author: Santanu Boral
*/
import { LightningElement, api, track } from 'lwc';
import getFieldsAndRecords from '@salesforce/apex/FieldSetHelper.getFieldsAndRecords';

export default class LwcFieldSetComponent extends LightningElement {
    
    @api recordId;  // record id from record detail page e.g. ''0012v00002WCUdxAAH'
    @api SFDCobjectApiName; //kind of related list object API Name e.g. 'Case'
    @api fieldSetName; // FieldSet which is defined on that above object e.g. 'CaseRelatedListFS'
    @api criteriaFieldAPIName; // This field will be used in WHERE condition e.g.'AccountId'
    @api firstColumnAsRecordHyperLink; //if the first column can be displayed as hyperlink

    @track columns;   //columns for List of fields datatable
    @track tableData;   //data for list of fields datatable
    
    recordCount; //this displays record count inside the ()
    lblobjectName; //this displays the Object Name whose records are getting displayed

    connectedCallback(){
        let firstTimeEntry = false;
        let firstFieldAPI;

        //make an implicit call to fetch records from database
        getFieldsAndRecords({ strObjectApiName: this.SFDCobjectApiName,
                                strfieldSetName: this.fieldSetName,
                                criteriaField: this.criteriaFieldAPIName,
                                criteriaFieldValue: this.recordId})
        .then(data=>{        
            //get the entire map
            let objStr = JSON.parse(data);   
            
            /* retrieve listOfFields from the map,
             here order is reverse of the way it has been inserted in the map */
            let listOfFields= JSON.parse(Object.values(objStr)[1]);
            
            //retrieve listOfRecords from the map
            let listOfRecords = JSON.parse(Object.values(objStr)[0]);

            let items = []; //local array to prepare columns

            /*if user wants to display first column has hyperlink and clicking on the link it will
                naviagte to record detail page. Below code prepare the first column with type = url
            */
            listOfFields.map(element=>{
                //it will enter this if-block just once
                if(this.firstColumnAsRecordHyperLink !=null && this.firstColumnAsRecordHyperLink=='Yes'
                                                        && firstTimeEntry==false){
                    firstFieldAPI  = element.fieldPath; 
                    //perpare first column as hyperlink                                     
                    items = [...items ,
                                    {
                                        label: element.label, 
                                        fieldName: 'URLField',
                                        fixedWidth: 150,
                                        type: 'url', 
                                        typeAttributes: { 
                                            label: {
                                                fieldName: element.fieldPath
                                            },
                                            target: '_blank'
                                        },
                                        sortable: true 
                                    }
                    ];
                    firstTimeEntry = true;
                } else {
                    items = [...items ,{label: element.label, 
                        fieldName: element.fieldPath}];
                }   
            });
            //finally assigns item array to columns
            this.columns = items; 
            this.tableData = listOfRecords;

            console.log('listOfRecords',listOfRecords);
            /*if user wants to display first column has hyperlink and clicking on the link it will
                naviagte to record detail page. Below code prepare the field value of first column
            */
            if(this.firstColumnAsRecordHyperLink !=null && this.firstColumnAsRecordHyperLink=='Yes'){
                let URLField;
                //retrieve Id, create URL with Id and push it into the array
                this.tableData = listOfRecords.map(item=>{
                    URLField = '/lightning/r/' + this.SFDCobjectApiName + '/' + item.Id + '/view';
                    return {...item,URLField};                     
                });
                
                //now create final array excluding firstFieldAPI
                this.tableData = this.tableData.filter(item => item.fieldPath  != firstFieldAPI);
            }

            //assign values to display Object Name and Record Count on the screen
            this.lblobjectName = this.SFDCobjectApiName;
            this.recordCount = this.tableData.length;
            this.error = undefined;   
        })
        .catch(error =>{
            this.error = error;
            console.log('error',error);
            this.tableData = undefined;
            this.lblobjectName = this.SFDCobjectApiName;
        })        
    }
}

FieldSetHelper.cls

Few notable points on this getFieldsAndRecords method:

  • Getting the instance of SObject based on strObjectApiName, the reflection has been used which is way more faster that globalDescribe.
  • Use of Schema.FieldSetMember to get the fields from Field Set. Refer Documentation
  • getFieldPath() of FieldSetMember gives the fieldAPI which has been used to build SOQL query
Finally, both field List and record List are getting stored in the Map and returned to js.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public with sharing class FieldSetHelper {
    @AuraEnabled (cacheable=true)
    public static String getFieldsAndRecords(String strObjectApiName, String strfieldSetName,
                                             String criteriaField, String criteriaFieldValue){
        Map<String, String> returnMap = new Map<String,String>();
        if(!String.isEmpty(strObjectApiName) && !String.isEmpty(strfieldSetName)){
            //get fields from FieldSet
            SObject sObj = (SObject)(Type.forName('Schema.'+ strObjectApiName).newInstance());
            List<Schema.FieldSetMember> lstFSMember = 
                sObj.getSObjectType().getDescribe().fieldSets.getMap().get(strfieldSetName).getFields();

	    //prepare SOQL query based on fieldAPIs	
	    String query = 'SELECT ';
	    for(Schema.FieldSetMember f : lstFSMember) {
	        query += f.getFieldPath() + ', ';
            }
            query += 'Id FROM ' + strObjectApiName ;

            //Just in case criteria field not specified then it will return all records
            if(!(String.isEmpty(criteriaField) && String.isEmpty(criteriaFieldValue))){
                query += ' WHERE ' + criteriaField + '=\'' + criteriaFieldValue + '\'';
            }
                        
	    //execute query
             List<SObject> lstRecords = Database.query(query);
            
             //prepare a map which will hold fieldList and recordList and return it
	     returnMap.put('FIELD_LIST', JSON.serialize(lstFSMember));
	     returnMap.put('RECORD_LIST', JSON.serialize(lstRecords));
	     return JSON.serialize(returnMap);
        }
        return null;
    }
}

Final Step

After building the component, place it on Record Page through App Builder and define attribute values.



You can see that component is displaying fields and records.

This concept can be leveraged easily at any project. The component can be improvised more like, handling events, based on Salesforce field type Datatable column types can be defined, it could be good to incorporate paginations etc, but it is a good start.

We are done and thanks for reading.



Further Reading