Saturday, May 29, 2021

Configurable Lightning Modal Popup with Field Set

 

Motivation behind this


Recently I got a requirement to build a configurable modal popup and it is to be developed using Lightning Web Components. Also, I got motivated watching Aditya Nag's (Salesforce) presentation on Design Patterns and Best Practices to build reusable Lightning Web Components following good design to develop this component.

The component is to be configurable so that even Admin can configure this. So, I have planned to leverage Field Set to make it. Earlier I have written a blog Build Configurable Dynamic Table from Field Set using Lightning Web Component.

Here design and event handling have been made in a different way. Let's get started.


Use Case


Business has a requirement to quickly create a record on a button click where fields will be displayed inside a modal form.

As, this requirement is quite common so developer wants to make it a reusable way using LWC. For testing purposes, create field set on Case Object.

Possible End Results


The component will look like this below:


 

Solution Approach


To develop this functionality, I have followed template-based designing, separation-of-concerns practices, and event propagations in a proper way.

Framed the following design:
  • There will be modal component modalComponentTemplate which will act as a template.
  • There will be a component createRecordFromFieldset which will develop screen elements using lightning-record-edit-form with a backend Apex calls to fetch field details from Field Set.
  • Spinner Component lwcSpinner for displaying spinner during load (here only HTML has been leveraged).
  • Container component lwcCreateRecordModalContainer will reuse this template and inject createRecordFromFieldset and Spinner into the unnamed slot and Submit & Cancel buttons to the footer slot. This component will expose to capture inputs from design attributes which is needed for Admin to set it up. This is the main component which drives the entire functionality.

Component composition is as follows:



Flow Diagram


The components will talk to each other as per the following diagram. This diagram depicts how components are loaded, events are propagated from parent-to-child and child-to-parent respecting each one's responsibilities.


Let's go through each components.

modalComponentTemplate


This template modal component code has been referred from lwc-recipes and customized for my need.

modalComponentTemplate.html

In this file, we can see there are sections for header, footer, and one unnamed slot that can be used for building body. There can be only one unnamed slot (<slot></slot>) in entire html.

 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
<!--
  @component   : modalComponentTemplate
  @description : This acts as a modal template, code has referred from lwcRecipe and slds modal
  @author      : Santanu Boral  
-->
<template>
    <template if:true={showModal}> 
        <section role="dialog" tabindex="-1" 
            aria-labelledby="modal-heading-01" 
            aria-modal="true" aria-describedby="modal-content-id-1" 
            class="slds-modal slds-fade-in-open"
        >
            <div class="slds-modal__container">
                <header class="slds-modal__header slds-theme_success">
                    <lightning-button-icon
                        class="slds-modal__close"
                        title="Close"
                        icon-name="utility:close"
                        icon-class="slds-button_icon-inverse"
                        onclick={handleDialogClose}
                    ></lightning-button-icon>
                    <h2 id="modal-heading-01" class="slds-modal__title slds-hyphenate" >
                        {header}
                    </h2>  
                </header>
                
                <!--unnamed slot where the implementer can inject their component-->
                <div class="slds-modal__content slds-p-around_medium" id="modal-content-id-1">
                    <slot></slot>
                </div>
                
                <footer class="slds-modal__footer">
                    <slot
                        name="footer"
                        onslotchange={handleSlotFooterChange}
                    ></slot>
                </footer>
            </div>
        </section>
        <div class="slds-backdrop slds-backdrop_open"></div>
    </template>
</template>


modalComponentTemplate.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
35
36
37
import { LightningElement,api } from 'lwc';

export default class ModelComponentTemplate extends LightningElement {
    showModal = false;
    @api
    set header(value) {
        this._headerPrivate = value;
    }
    get header() {
        return this._headerPrivate;
    }
    
    _headerPrivate;
    
    @api show() {
        this.showModal = true;
    }

    @api hide() {
        this.showModal = false;
    }
    handleDialogClose() {
        //Let parent know that dialog is closed (mainly by that cross button)
        // so it can set proper variables if needed
        const closedialog = new CustomEvent('closedialog');
        this.dispatchEvent(closedialog);
        this.hide();
    }
    
    handleSlotFooterChange() {
        // Only needed in "show" state. If hiding, we're removing from DOM anyway
        if (this.showModal === false) {
            return;
        }
        const footerEl = this.template.querySelector('footer').hide();        
    }
}

Let's go through Create Record Form component.

createRecordFromFieldset.html


In this file lightning-record-edit-form has been used, there will be no buttons, just the fields will be displayed from field set. Form submission will be handled from parent i.e. Container component. Sounds interesting!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!--
  @component   : createRecordFromFieldset
  @description : This component displays fields from fieldset. 
                 There is no submit button in this component, form is submitted programmatically.
  @author      : Santanu Boral  
-->
<template>
  <lightning-record-edit-form 
        object-api-name={sfdcObjectApiName} 
        onsuccess={handleSuccess}>
    <lightning-messages></lightning-messages>
    <template for:each={inputFieldAPIs} for:item="item">  
        <lightning-input-field field-name={item} name={item} key={item} id={item}>          
        </lightning-input-field>
</template> </lightning-record-edit-form>
</template>

createRecordFromFieldset.js


Few notable points in this js file:
  • Container will pass the values of sfdcObjectApiName and fieldSetName as attributes.
  • implicit call to Apex class to fetch field details in connectedCallback()
  • In the renderedCallback() cmploaded event has been raised, so that spinner can be closed by Container. Check Container js code an interesting way to hide spinner.
  • handleSubmit public method is called from Container to submit the lightning-record-edit-form

this.template.querySelector('lightning-record-edit-form').submit();
  • It will automatically fire onsuccess event and handleSuccess() handler will propagate postsuccess event to Container so the modal can be closed.

 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
/*
 *   Author: Santanu Boral
*/
import { LightningElement,api, track } from 'lwc';
import getFieldsFromFieldSet from '@salesforce/apex/FieldSetHelper.getFieldsFromFieldSet';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

export default class CreateRecordFromFieldset extends LightningElement {
    lblobjectName; //this displays the Object Name whose records are getting displayed
    inputFieldAPIs = [];
    renderCall = false;

    //get its value from container component through attributes
    @api sfdcObjectApiName; //Case
    @api fieldSetName; //QuickCaseFS

    //load the record edit form with fields from fieldset.
    connectedCallback(){
        let objectApiName = this.sfdcObjectApiName;
        let fieldSetName = this.fieldSetName;

        //make an implicit call to fetch fields from database
        getFieldsFromFieldSet({ strObjectApiName: objectApiName,
            strfieldSetName: fieldSetName}
        )
        .then(data=>{        
            let items = []; //local array to hold the field api

            //get the entire map
            let objStr = JSON.parse(data);   
            //get the list of fields, its a reverse order to extract from map
            let listOfFields = JSON.parse(Object.values(objStr)[1]);
            //get the object name
            this.lblobjectName = Object.values(objStr)[0];
            //prepare items array using field api names            
            listOfFields.map(element=>items.push(element.fieldPath));      
                  
            this.inputFieldAPIs = items;                              
            this.error = undefined;   
        })
        .catch(error =>{
            this.error = error;
            console.log('error',error);                
            this.lblobjectName = objectApiName;
        })
    }
    
    renderedCallback(){
        if(!this.renderCall){
            this.renderCall = true;
            this.dispatchEvent(
                new CustomEvent('cmploaded')
            );            
        }
    }

    //This method submits the record edit form and getting called from container component
    @api
    handleSubmit(){        
        this.template.querySelector('lightning-record-edit-form').submit();        
    }

    //This event handler fires on post submit and displays success and propagate event to container
    handleSuccess(event) {
        const evt = new ShowToastEvent({
            title:  this.lblobjectName + " created",
            message: "Record ID: " + event.detail.id,
            variant: "success"
        });
        this.dispatchEvent(evt);

        //raise event to parent to handle this and to close the popup, specify bubbles=true
        this.dispatchEvent(
                new CustomEvent('postsuccess'),
                { bubbles: true }
        );        
    }
}

Let's check the apex class which fetches field details from field set.

FieldSetHelper.cls


Few notable points:
  • Using reflection, SObject instance has been created which must faster than Schema.getGlobalDescibe().

(SObject)(Type.forName('Schema.'+ strObjectApiName)?.newInstance());
         
    • Use of Safe Navigation Operator to avoid Null Point Exceptions
    • Object label has been returned which will support language translation.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /**
     * @author : Santanu Boral 
    **/
    public with sharing class FieldSetHelper {
        
        @AuraEnabled (cacheable=true)
        public static String getFieldsFromFieldSet(String strObjectApiName, String strfieldSetName){
            if(!String.isEmpty(strObjectApiName) && !String.isEmpty(strfieldSetName)){   
                Map<String, String> returnMap = new Map<String, String>();
    
                //get fields from FieldSet
                SObject sObj = (SObject)(Type.forName('Schema.'+ strObjectApiName)?.newInstance());
                List<Schema.FieldSetMember> lstFSMember = 
                    sObj?.getSObjectType()?.getDescribe()?.fieldSets.getMap().get(strfieldSetName)?.getFields();
                    
                returnMap.put('FIELD_LIST',JSON.serialize(lstFSMember));
                returnMap.put('OBJECT_LABEL', sObj?.getSObjectType()?.getDescribe()?.getLabel());
    
                return JSON.serialize(returnMap);
            }                                                                 
            return null;
        }
    }
    
     
    There is small Spinner component has been created just to use HTML.

    lwcSpinner


    HTML as below

    <template>    
        <div class="slds-spinner_container">
            <div class="slds-spinner_brand  slds-spinner slds-spinner_large slds-is-relative" role="alert">
                <span class="slds-assistive-text">Loading...</span>
                <div class="slds-spinner__dot-a"></div>
                <div class="slds-spinner__dot-b"></div><br>
                <br>            
            </div>
        </div>    
    </template>
    

    css as below

    We will see how we have made it hidden using css classList in Container component.

    .slds-spinner_container {     
        content: 'Please wait.....!';
        text-align: center;
        z-index: 10000;
        position: fixed;
        background-color: rgba(0, 0, 0, 0.002);
    }
    
    .spinner-hidden {
        display: none;
    }
    

    Let's move on final Container component.

    lwcCreateRecordModalContainer


    This component holds showModal button for opening modal, injects child components to the slots and submits the form.


    lwcCreateRecordModalContainer.html


     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
    <!--
      @component   : lwcCreateRecordModalContainer
      @description : This acts as pop-up container which re-uses popup template 
                     and injects fieldset component and buttons into the slots.
      @author      : Santanu Boral 
    -->
    <template>
        <lightning-card title="Create quick case" icon-name="custom:custom19">
            <!--show modal button on open modal-->
            <lightning-button
                    label="Show modal"
                    onclick={handleShowModal}
            ></lightning-button>        
            
            <c-modal-component-template 
                header={header} 
                onclosedialog={handleCancelModal}            
                >
                <!--display create record component inside unnamed slot-->
                <template if:true={showSpinner}>                
                    <c-lwc-spinner></c-lwc-spinner>            
                </template>
                <c-create-record-from-fieldset 
                        sfdc-object-api-name={SFDCobjectApiName} 
                        field-set-name={fieldSetName}   
                        onpostsuccess={handlePostSuccess}
                        oncmploaded={handleCreateRecordCmpLoaded}             
                ></c-create-record-from-fieldset>
    <!-- inject footer submit & cancel buttons into the slot--> <div slot="footer"> <lightning-button label="Cancel" variant="neutral" onclick={handleCancelModal} ></lightning-button>&nbsp;
    <lightning-button label="Submit" type="submit" variant="brand" onclick={handleSubmitModal} ></lightning-button>
    </div> </c-modal-component-template>
    </lightning-card>
    </template>

    lwcCreateRecordModalContainer.js


    Few notable points:
    • It passed attribute values to createRecord component which is getting captured through design attributes like: sfdc-object-api-name={SFDCobjectApiName}
    • Spinner loaded when this component is loaded.
    • After createRecord component loads all the fields then based on oncmploaded event handler, spinner is made hidden. The below code adds display: none into the css class to hide this. No other if-else logic written.

    this.template.querySelector('c-lwc-spinner').classList.add(CSS_CLASS);
      • After createRecord component saves the records, it captures onpostsuccess event and hides the modal.


       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
      /*
       *   Author: Santanu Boral
      */
      import { LightningElement,api } from 'lwc';
      
      const CSS_CLASS = 'spinner-hidden';
      export default class LwcCreateRecordModalContainer extends LightningElement {    
      
          //following values are passed from design attributes
          @api header; //Create Case 
          @api SFDCobjectApiName; //Case
          @api fieldSetName; //'QuickCaseFS'
          showSpinner;
      
          //this calls for modal to display
          handleShowModal() {    
              this.showSpinner = true;
              this.template.querySelector('c-modal-component-template').show();                
          }
      
          //this makes modal is hidden
          handleCancelModal() {
              this.template.querySelector('c-modal-component-template').hide();        
          }
      
          //This event handler calls the handleSubmit public method of fieldset component
          handleSubmitModal(event){
              this.template.querySelector('c-create-record-from-fieldset').handleSubmit();            
          }
      
          //This postSuccess event handler closes the modal
          handlePostSuccess(event){
              event.stopPropagation();
              this.handleCancelModal();
          }
      
          //This makes spinner to be hidden
          handleCreateRecordCmpLoaded(event){
              setTimeout(()=>{
                  this.template.querySelector('c-lwc-spinner').classList.add(CSS_CLASS);
                  this.showSpinner = false;
              }, 1200);        
          }
      }
      

      Finally, we are done with preparing the components.

      Let's Test this!


      First create a field set QuickCaseFS with Subject, Status, Origin and Description.




      Create a page from AppBuilder and place lwcCreateRecordModalContainer component and pass the values.


      Finally, open the page and click on Show Modal button and component will open.

      I have also created a validation rule on Case object to make Subject as Mandatory which works wells during submit as follows.





      Issue fixing


      We might face an issue if we have a dropdown list and when we click on dropdown, there is a right side scrollbar and user will face hard time to scroll and choose the item. This issue can be fixed as below.


      Atlast, we are done and thanks for your patience to read this!

      References


      Further Reading




      1 comment:

      1. step No 2, when I place this component in the Lightning page, I am not getting the options to configure the attributes.
        Did I miss something in my xml file?

        ReplyDelete