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.
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> |
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> |
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> |
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.
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.
step No 2, when I place this component in the Lightning page, I am not getting the options to configure the attributes.
ReplyDeleteDid I miss something in my xml file?