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


Sunday, October 11, 2020

Generate PDF from Salesforce Lightning Web Component

 

Motivation behind this


I have been looking for this option to generate PDF file from Lightning Web Component (LWC) quite often. Salesforce didn't provide any support to render page or component as pdf (like Visualforce) in LWC. So, tried a find an option to do so.

Without using external JavaScript library, I have tried to achieve here. This concept can be leveraged for any use case for pdf generation.

Use Case


Business has requirement to send user input data or data fetched from database to be saved as pdf format.

Developer wants to build with LWC.

Possible End Result


After building the use case, it will perform the functionality as following video:



Solution Approach


The main challenges with this use case:

  • Till today, Salesforce doesn't provide any JS library to display page as pdf
  • If we try to use Visualforce with renderAs="pdf" with embedding LWC into it then it will not work, because this doesn't support any JavaScript to be included.
  • There are many third party JS library can be used but maintaining that is a challenge.

Approach has been taken following way:
  • Create a LWC component adding lightning-input-rich-text field. This field has value attribute which returns the HTML content (this is main trick)
  • Create a Visualforce page with renderAs="pdf" attribute and use those HTML text as value of apex:outputText with escape="false". Here, visualforce has been used only for pdf generation, nothing else. So it will be very slim. 
  • When we click on "Save As PDF" button it will implicitly call the apex class' method and use the PageReference of the visualforce and save this body content as pdf.
It's simple.

displayRichTextComponent:

displayRichTextComponent.html will prepare a screen like this:



Code as follows:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
    <lightning-card>    
        <lightning-input-rich-text
            placeholder="Type something interesting"
            formats={allowedFormats}
            value={myVal}>
        </lightning-input-rich-text>
        <lightning-button label="Save as PDF"
                        onclick={saveAsPdf}>
        </lightning-button>
        <lightning-button label="Do Something"
                        onclick={handleClick}>
        </lightning-button>
    </lightning-card>
</template>

Few notable points on the above HTML:

  • lightning-input-rich-text supports those format of font, size, image etc. Refer Documentation
  • value attribute of  lightning-input-rich-text shows initial data
  • Save as PDF button click event calls saveAsPdf method.
  • Clicking  on "Do Something" button, replaces any selected text with "Journey to Salesforce" with defined format with setRangeText() method, which is still in beta (Winter 21 release)
displayRichTextComponent.js

Entire code as below



 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
import { LightningElement } from 'lwc';
import generatePDF from '@salesforce/apex/DisplayRichTextHelper.generatePDF';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

export default class DisplayRichTextComponent extends LightningElement {
    allowedFormats =  ['font', 'size', 'bold', 'italic', 'underline', 'strike',
    'list', 'indent', 'align', 'link', 'image', 'clean', 'table', 'header', 'color',
    'background','code','code-block'];

    //this method will display initial text
    get myVal() {
        return '**Generate PDF using LWC Component**';
    }

    attachment; //this will hold attachment reference

    /*This method extracts the html from input rich text 
        and pass this to apex class' method via implcit call
    */
    saveAsPdf(){
        const editor = this.template.querySelector('lightning-input-rich-text');
        
        //implicit calling apex method
        generatePDF({txtValue: editor.value})
        .then((result)=>{
            this.attachment = result;
                console.log('attachment id=' + this.attachment.Id);
                //show success message
                this.dispatchEvent(
                    new ShowToastEvent({
                        title: 'Success',
                        message: 'PDF generated successfully with Id:' + this.attachment.Id,
                        variant: 'success',
                    }),
                );
        })
        .catch(error=>{
            //show error message
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Error creating Attachment record',
                    message: error.body.message,
                    variant: 'error',
                }),
            );
        })
    }
    
    /*
        This method updates the selected text with defined format
    */
    handleClick() {
        const editor  = this.template.querySelector('lightning-input-rich-text');
        const textToInsert = 'Journey to Salesforce'
        editor.setRangeText(textToInsert, undefined, undefined, 'select')
        editor.setFormat({bold: true, size:24, color: 'green', align: 'center',});
    }
}


Now, let's talk about visualforce page

renderAsPdf.page


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<apex:page controller="DisplayPDFController" renderAs="pdf"  
		   applyHtmlTag="false" showHeader="false" cache="true" readOnly="true" >
    <html>
        <head>
            <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
            <style>
                @page {
                    size: a4 portrait;    
                    padding-left: 2px;    
                    padding-right: 2px;
                }            
            </style>
        </head>
        <apex:outputText value = "{!displayText}" escape = "false"/>
    </html>
</apex:page>

You can see apex:outputText is used to display content, be sure to escape. I have added a style to display it as portrait with some padding option.

Now, see Visualforce Controller

DisplayPDFController.cls


1
2
3
4
5
6
7
8
public with sharing class DisplayPDFController {

    public String displayText {get; set;}
    public DisplayPDFController() {
        displayText = String.escapeSingleQuotes(
            ApexPages.currentPage().getParameters().get('displayText'));
    }
}

In the constructor, values are assigned to displayText. It can be done in page action method.

Finally, the Apex Class which is getting called from js file which actually creates the pdf file.

DisplayRichTextHelper.cls

Here, based on PageReference we are getting the page content which is being converted to pdf using getContentAspdf() method.

When we initially try to save attachment, we could face this below error if (cacheable=true) is used with @AuraEnabled

Too many DML statements: 1 out of 0 Error

which means that component is readonly and it doesn't allow to perform DML operation.

That's why cacheable=true is omitted. 

The file is getting attached to a contact record. For sake of brevity, error handling has been omitted and hardcoded Contact Id has been used.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public with sharing class DisplayRichTextHelper {
    
    @AuraEnabled
    public static Attachment generatePDF(String txtValue){
        
        Pagereference pg = Page.renderAsPDF;
        pg.getParameters().put('displayText', txtValue);

        Contact con = new Contact(Id='0032v00002ypAntAAE');
        Attachment objAttachment = new Attachment();
        objAttachment.Name = 'J2S.pdf';
        objAttachment.ParentId = con.Id;
        objAttachment.Body = pg.getContentaspdf();   
        objAttachment.IsPrivate = false;
        insert objAttachment;
        return objAttachment;
    }

}

Final pdf


The output is showing based on the element added into the rich text field.



meta files should include where this component will be available.

Create a Lightning App Builder page with one region and place this component and run the application, it will display the screen as above.

This concept can be leveraged easily at any project. For example, capturing fields from screen, then display results with some formats into the rich text box and finally generating pdf.

We are done and thanks for reading.


References



Further Reading