Friday 13 March 2020

Creating - Lightning Dynamic Question & Answer Form


Requirement

Need to display a Question & Answer form based upon input received from 3rd party web service i.e. web service response will tell us 
  • What Question text to be displayed
  • Answer type might be of Single/Multiple Choice/Free Text based (to keep it simple, I have not included other types like Date, Numeric etc)
  • Some of questions are not mandatory to be answered

End of the implementation we can see slimier UI as displayed below:






Sample response (JSON) from 3rd party Service:

 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
[
  {
    "questionId": "Q0001",
    "question": "How many tickets do you have?",
    "required": true,
    "questionDisplayType": "PICKLIST",
    "answerChoices": ["1","2","3"]
  },
  {
    "questionId": "Q00002",
    "question": "What is your real name?",
    "required": false,
    "readonly": false,
    "questionDisplayType": "TEXT"
  },
  {
    "questionId": "Q00003",
    "question": "What is your favorite color?",
    "required": true,
    "readonly": false,
    "questionDisplayType": "RADIOGROUP",
    "answerChoices": ["Red","Blue","Yellow","Green"]
  },
  {
    "questionId": "Q00004",
    "question": "Select countries you've visited",
    "required": true,
    "readonly": false,
    "questionDisplayType": "CHECKBOXGROUP",
    "answerChoices": ["India","USA","UK"]
  }
]

Solution Approach:

Will create a reusable component (CommonInputComponent.cmp) which will render/generate required input components based upon display type provided from parent (Q_A_Form.cmp) component. And finally will capture Answers from parent component itself.

Note: Also given code repository link at the button of this post.

Now its code time: 

Code is very self -explanatory but I have explained using component level comments as required.

Child Component: CommonInputComponent.cmp
 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
<!--
  @File Name          : CommonInputComponent.cmp
  @Description        : Input type is determined based on the display type provided
  @Author             : Avijit Gorai
  @Group              : 
  @Last Modified By   : Avijit Gorai
  @Last Modified On   : 11/3/2020, 1:12:47 am
  @Modification Log   : 
  Ver       Date            Author            Modification
  1.0    11/3/2020   Avijit Gorai     Initial Version
-->
<aura:component
    implements="flexipage:availableForAllPageTypes,flexipage:availableForRecordHome,forceCommunity:availableForAllPageTypes"
    access="global">

    <!-- Attributes -->
    <aura:attribute name="questionId" type="String" required="true" />
    <aura:attribute name="questionName" type="String" required="true" />
    <aura:attribute name="questionDisplayType" type="String" required="true" />
    <aura:attribute name="picklistOptions" type="Object[]" />
    <aura:attribute name="required" type="Boolean" default="false" />
    <aura:attribute name="disabled" type="Boolean" default="false" />
    <aura:attribute name="readonly" type="Boolean" default="false" />
    <aura:attribute name="fieldMetadata" type="Object" access="private" />
    <aura:attribute name="fieldValue" type="Object" access="public" />
    <aura:attribute name="fieldValueChb" type="List" access="public" />
    <aura:attribute name="answerChoices" type="List" default="[]" />

    <!-- aura method, getting called from parent component to check input validaty and show message accordingly -->
    <aura:method name="checkReportValidity" action="{!c.showReportValidity}" access="public"></aura:method>

    <!-- Handlers -->
    <aura:handler name="init" value="{!this}" action="{!c.doInit}" />

    <!-- TEXT -->
    <aura:if isTrue="{!v.questionDisplayType == 'TEXT'}">
        <lightning:input aura:id="inputField" value="{!v.fieldValue}" label="{!v.questionName}"
            onchange="{!c.handleFieldValueChanged}" maxlength="{!v.fieldMetadata.maxLength}" required="{!v.required}"
            disabled="{!v.readonly}" />
    </aura:if>

    <!-- PICKLIST -->
    <aura:if isTrue="{!v.questionDisplayType == 'PICKLIST'}">
        <lightning:select aura:id="inputField" label="{!v.questionName}" value="{!v.fieldValue}"
            required="{!v.required}" disabled="{!v.readonly}" onchange="{!c.handleFieldValueChanged}">
            <aura:iteration items="{!v.picklistOptions}" var="picklistOption">
                <option text="{!picklistOption.label}" value="{!picklistOption.value}" />
            </aura:iteration>
        </lightning:select>
    </aura:if>

    <!-- RADIOGROUP -->
    <aura:if isTrue="{!v.questionDisplayType == 'RADIOGROUP'}">
        <lightning:radioGroup aura:id="inputField" label="{!v.questionName}" options="{!v.picklistOptions}"
            value="{!v.fieldValue}" type="radio" onchange="{!c.handleFieldValueChanged}" required="{!v.required}"
            disabled="{!v.readonly}" class="customRadioCls" />
    </aura:if>

    <!-- CHECKBOXGROUP -->
    <aura:if isTrue="{!v.questionDisplayType == 'CHECKBOXGROUP'}">
        <lightning:checkboxGroup aura:id="inputField" label="{!v.questionName}" options="{!v.picklistOptions}"
            value="{!v.fieldValueChb}" onchange="{!c.handleFieldValueChanged}" required="{!v.required}"
            disabled="{!v.readonly}" />
    </aura:if>

</aura:component>

CommonInputComponentController.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
({
    //CommonInputComponentController.js
    
    doInit: function (component, event, helper) {
        helper.setFieldMetadata(component, event);
    },
    handleFieldValueChanged: function (component, event, helper) {
        helper.handleFieldValueChanged(component, event);
    },
    //Shows the help message if the form control is in an invalid state.
    showReportValidity: function (component, event, helper) {
        component.find("inputField").showHelpMessageIfInvalid();
    }
});

CommonInputComponentHelper.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
38
39
40
41
42
43
44
45
46
({
    //CommonInputComponentHelper.js
    
    //setting metadata (options to select) 
    setFieldMetadata: function (component, event) {

        var fieldMetadata = new Object();

        fieldMetadata.questionDisplayType = component.get('v.questionDisplayType');

        if (fieldMetadata.questionDisplayType === 'TEXT') {
            fieldMetadata.maxLength = 180;
        }

        if (fieldMetadata.questionDisplayType === 'PICKLIST' ||
            fieldMetadata.questionDisplayType === 'RADIOGROUP' ||
            fieldMetadata.questionDisplayType === 'CHECKBOXGROUP') {
            //console.log('answerChoices -> ' + component.get('v.answerChoices'));
            var answerChoices = component.get('v.answerChoices');
            if (answerChoices) {
                console.log(answerChoices);
                var result = [];
                if (fieldMetadata.questionDisplayType !== 'RADIOGROUP' && fieldMetadata.questionDisplayType !== 'CHECKBOXGROUP') {
                    result.push({label: '', value: ''}); //inserting blank option
                }
                for (var i in answerChoices) {
                    result.push({
                        label: answerChoices[i],
                        value: answerChoices[i]
                    });
                }
                fieldMetadata.picklistOptions = result;
                fieldMetadata.picklistOptions.sort((a, b) => (a.value > b.value) ? 1 : -1); //sorting
                component.set('v.picklistOptions', fieldMetadata.picklistOptions);
            }
        }
        component.set('v.fieldMetadata', fieldMetadata);
    },
 
    //onchange, assigning changed value to attribute 'fieldValue'. 
    //This is helpful to get/access value of the input component from parent component
    handleFieldValueChanged: function (component, event) {
        var newFieldValue = event.getParam('value') !== undefined ? event.getParam('value') : event.getSource().get('v.value');
        component.set('v.fieldValue', newFieldValue);
    }
})

Parent Component: Q_A_Form.cmp
 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
<!--
  @File Name          : Q_A_Form.cmp
  @Description        : 
  @Author             : Avijit Gorai
  @Group              : 
  @Last Modified By   : Avijit Gorai
  @Last Modified On   : 13/3/2020, 12:28:51 am
  @Modification Log   : 
  Ver       Date            Author            Modification
  1.0    11/3/2020   Avijit Gorai     Initial Version
-->

<aura:component implements="forceCommunity:availableForAllPageTypes" access="global">
    <aura:attribute name="questionAnswerMap" type="List" default="[]" />
    <aura:handler name="init" value="{!this}" action="{!c.doInit}" />

    <div class="slds-p-around_x-large">
        <!--Rendering CommonInputComponent based upon data received, each of component having aura id = 'fieldId' -->
        <aura:iteration items="{!v.questionAnswerMap}" var="fieldValue">
            <c:CommonInputComponent aura:id="fieldId" questionId="{!fieldValue.questionId}"
                questionName="{!fieldValue.question}" required="{!fieldValue.required}"
                readonly="{!fieldValue.readonly}" questionDisplayType="{!fieldValue.questionDisplayType}"
                answerChoices="{!fieldValue.answerChoices}" />
        </aura:iteration>
        <br /><br />
        <lightning:button onclick="{!c.submit}" label="Submit" iconName="utility:save" iconPosition="left"
            variant="brand" />
    </div>
</aura:component>

Q_A_FormController.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
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
({
    //Q_A_FormController.js

    doInit: function (component, event, helper) {
        //data received from web service (JSON) - to keep this demo simple, I have omitted Webservice call
        //this is the JSON equivalent data in Object format
        var questions = [];
        var qaData = new Object();
        qaData.questionId = "Q0001";
        qaData.question = "How many tickets do you have?";
        qaData.required = true;
        qaData.questionDisplayType = "PICKLIST";
        qaData.answerChoices = ["1", "2", "3"];
        questions.push(qaData);

        qaData = new Object();
        qaData.questionId = "Q00002";
        qaData.question = "What is your real name?";
        qaData.required = false;
        qaData.readonly = false;
        qaData.questionDisplayType = "TEXT";
        questions.push(qaData);

        qaData = new Object();
        qaData.questionId = "Q00003";
        qaData.question = "What is your favorite color?";
        qaData.required = true;
        qaData.readonly = false;
        qaData.questionDisplayType = "RADIOGROUP";
        qaData.answerChoices = ["Red", "Blue", "Yellow", "Green"];
        questions.push(qaData);

        qaData = new Object();
        qaData.questionId = "Q00004";
        qaData.question = "Select countries you've visited";
        qaData.required = true;
        qaData.readonly = false;
        qaData.questionDisplayType = "CHECKBOXGROUP";
        qaData.answerChoices = ["India", "USA", "UK"];
        questions.push(qaData);
        console.log(questions);

        console.log(JSON.stringify(questions));
        component.set("v.questionAnswerMap", questions);
    },

    submit: function (component, event, helper) {
        var requiredMissing = false;
        //finding list of rendered component based upon aura id = fieldId. This will give us a array of component
        const cmps = component.find("fieldId"); 
        if (!cmps) return;
        //looping through to check if current component's value is required but input value has not been provided
        //then calling checkReportValidity aura method to check its input validaty and show message accordingly
        cmps.forEach(function (cmp) {
            let selectedVal = cmp.get("v.fieldValue");
            console.log(cmp.get("v.questionId") + " -- " + selectedVal);
            if (cmp.get("v.required") && (!selectedVal || selectedVal.length == 0)) {
                requiredMissing = true;
                cmp.checkReportValidity();
                console.log("Required field is missing for " + cmp.get("v.questionId"));
            }
        });

        if (requiredMissing) {
            console.log("requireFieldMissing");
        } else {
            //all fine then collecting input value from each of components and added to a Map for further use as per business need
            let answersMap = new Map();
            cmps.forEach(function (cmp) {
                let fieldValue = cmp.get("v.fieldValue");
                answersMap.set(cmp.get("v.questionId"), fieldValue === undefined ? "" : fieldValue);
            });
            
            console.log("answersMap --> ", answersMap);

            let successMapStr = JSON.stringify(Object.fromEntries(answersMap.entries()));
            helper.showTosteMessage(
                component,
                "",
                "success",
                successMapStr,
                "dismissible"
            );
        }
    }
});

Q_A_FormHelper.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
({
  //Q_A_FormHelper.js

  showTosteMessage: function(component, title, type, message, mode) {
    var toastEvent = $A.get("e.force:showToast");
    if (toastEvent) {
      toastEvent.setParams({
        title: title,
        type: type,
        message: message,
        mode: mode
      });
      toastEvent.fire();
    }
    // if not running in LEX or SF1, toast is not available - use alert
    else {
      alert(title + ": " + message);
    }
  }
});

Code has been kept @ https://github.com/AvijitGoraiGitHub/dynamicUILightning


Saturday 22 February 2020

File Upload into AWS S3 using Salesforce LWC leveraging AWS SDK

Recently I was building a demo where AWS S3 bucket to be used as a document repository. Actual requirement is much bigger but I am going to demonstrate a core piece of it i.e file upload to AWS S3 bucket through Salesforce LWC (Lightning Web Component) using AWS SDK Javascript.

You must be wondering what is AWS SDK?
The AWS SDK for JavaScript enables you to directly access AWS services from JavaScript code running in the browser. Authenticate users through Facebook, Google, or Login with Amazon using web identity federation. Store application data in Amazon DynamoDB, and save user files to Amazon S3.
  1. Direct calls to AWS services mean no server-side code (Apex) is needed to consume AWS APIs.
  2. Using nothing but common web standards - HTML, CSS, and JavaScript - you can build full-featured dynamic browser applications.
  3. No Signature Version 4 Signing Process is needed as SDK does it for you internally.
For more information, follow AWS SDK Javascript

Prerequisite: You should have
  1. AWS Account
  2. S3 bucket configured
  3. AWS Access Key Id
  4. AWS Secret Access Key
For more information, follow UserGuide Credentials Access Key

Its demo time:

In order to make this demo simple, I have created a file upload component where in a button click I am uploading the file to AWS S3 bucket.

All AWS related configuration are kept in a custom metadata type called AWS_Setting__mdt.

fileupload_aws_s3bucket.html will display a screen as below:



 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- fileupload_aws_s3bucket.html -->
<template>
    <lightning-card variant="Narrow" title="AWS S3 File Uploader" style="width:30rem" icon-name="action:upload">
        <div class="slds-m-top_medium slds-m-bottom_x-large">
            <!-- Single file -->
            <div class="slds-p-around_medium lgc-bg">
                <lightning-input type="file" onchange={handleSelectedFiles}></lightning-input>
                {fileName}
            </div>
            <div class="slds-p-around_medium lgc-bg">
                <lightning-button class="slds-m-top--medium" label="Upload to AWS S3 bucket" onclick={uploadToAWS}
                    variant="brand">
                </lightning-button>
            </div>
        </div>
        <template if:true={showSpinner}>
            <lightning-spinner size="medium">
            </lightning-spinner>
        </template>
    </lightning-card>
</template>


fileupload_aws_s3bucket.js : Code is very self -explanatory but I am trying to explain it.

At first we need add AWS SDK JS file in a Salesforce static resource. Here is the link of AWS SDK JS file.
To use a JavaScript library from a third-party site, add it to a static resource, and then add the static resource to our component. After the library is loaded from the static resource, we can use it as normal.
Then use import { loadScript } to load the resources in LWC component in renderedCallback hook.
By default, we can’t make WebSocket connections or calls to third-party APIs from JavaScript code. To do so, add a remote site as a CSP Trusted Site.
The Lightning Component framework uses Content Security Policy (CSP), which is a W3C standard, to control the source of content that can be loaded on a page. The default CSP policy doesn’t allow API calls from JavaScript code. We change the policy, and the content of the CSP header, by adding CSP Trusted Sites.
In this case I have added *.amazonaws.com in CSP. Special thanks to Mohith (@msrivastav13) who helped me to point out what domain/url to be added under CSP.


Once SDK loaded, I am fetching AWS related configuration from AWS_Setting__mdt by assigning metadata record Id to awsSettngRecordId (its both dynamic and reactive) which interns invoke @wire service to provisions data.

From @wire service, initializeAwsSdk method is getting called to initialize AWS SDK based upon configuration data received.

Since SDK is properly initialized, now we are ready to upload documents. On click event from button I am uploading documents through uploadToAWS method. Then it is actually calling SDK's predefined method putObject in order to upload the document to S3 bucket. Please refer AWS.S3 for more information related to supported methods by the SDK .

Please Note : Access Id and Secret Key is open for all to see.
  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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/* fileupload_aws_s3bucket.js */
/* eslint-disable no-console */
import { LightningElement, track, wire } from "lwc";
import { getRecord } from "lightning/uiRecordApi";
import { loadScript } from "lightning/platformResourceLoader";
import AWS_SDK from "@salesforce/resourceUrl/AWSSDK";

export default class Fileupload_aws_s3bucket extends LightningElement {
  /*========= Start - variable declaration =========*/
  s3; //store AWS S3 object
  isAwsSdkInitialized = false; //flag to check if AWS SDK initialized
  @track awsSettngRecordId; //store record id of custom metadata type where AWS configurations are stored
  selectedFilesToUpload; //store selected file
  @track showSpinner = false; //used for when to show spinner
  @track fileName; //to display the selected file name

  /*========= End - variable declaration =========*/

  //Called after every render of the component. This lifecycle hook is specific to Lightning Web Components,
  //it isn’t from the HTML custom elements specification.
  renderedCallback() {
    if (this.isAwsSdkInitialized) {
      return;
    }
    Promise.all([loadScript(this, AWS_SDK)])
      .then(() => {
        //For demo, hard coded the Record Id. It can dynamically be passed the record id based upon use cases
        this.awsSettngRecordId = "m012v000000FMQJ";
      })
      .catch(error => {
        console.error("error -> " + error);
      });
  }

  //Using wire service getting AWS configuration from Custom Metadata type based upon record id passed
  @wire(getRecord, {
    recordId: "$awsSettngRecordId",
    fields: [
      "AWS_Setting__mdt.S3_Bucket_Name__c",
      "AWS_Setting__mdt.AWS_Access_Key_Id__c",
      "AWS_Setting__mdt.AWS_Secret_Access_Key__c",
      "AWS_Setting__mdt.S3_Region_Name__c"
    ]
  })
  awsConfigData({ error, data }) {
    if (data) {
      let awsS3MetadataConf = {};
      let currentData = data.fields;
      //console.log("AWS Conf ====> " + JSON.stringify(currentData));
      awsS3MetadataConf = {
        s3bucketName: currentData.S3_Bucket_Name__c.value,
        awsAccessKeyId: currentData.AWS_Access_Key_Id__c.value,
        awsSecretAccessKey: currentData.AWS_Secret_Access_Key__c.value,
        s3RegionName: currentData.S3_Region_Name__c.value
      };
      this.initializeAwsSdk(awsS3MetadataConf); //Initializing AWS SDK based upon configuration data
    } else if (error) {
      console.error("error ====> " + JSON.stringify(error));
    }
  }

  //Initializing AWS SDK
  initializeAwsSdk(confData) {
    const AWS = window.AWS;
    AWS.config.update({
      accessKeyId: confData.awsAccessKeyId, //Assigning access key id
      secretAccessKey: confData.awsSecretAccessKey //Assigning secret access key
    });

    AWS.config.region = confData.s3RegionName; //Assigning region of S3 bucket

    this.s3 = new AWS.S3({
      apiVersion: "2006-03-01",
      params: {
        Bucket: confData.s3bucketName //Assigning S3 bucket name
      }
    });
    this.isAwsSdkInitialized = true;
  }

  //get the file name from user's selection
  handleSelectedFiles(event) {
    if (event.target.files.length > 0) {
      this.selectedFilesToUpload = event.target.files[0];
      this.fileName = event.target.files[0].name;
      console.log("fileName ====> " + this.fileName);    }
  }

  //file upload to AWS S3 bucket
  uploadToAWS() {
    if (this.selectedFilesToUpload) {
      this.showSpinner = true;
      let objKey = this.selectedFilesToUpload.name
        .replace(/\s+/g, "_") //each space character is being replaced with _
        .toLowerCase();

      //starting file upload
      this.s3.putObject(
        {
          Key: objKey,
          ContentType: this.selectedFilesToUpload.type,
          Body: this.selectedFilesToUpload,
          ACL: "public-read"
        },
        err => {
          if (err) {
            this.showSpinner = false;
            console.error(err);
          } else {
            this.showSpinner = false;
            console.log("Success");
            this.listS3Objects();
          }
        }
      );
    }
  }

  //listing all stored documents from S3 bucket
  listS3Objects() {
    //console.log("AWS -> " + JSON.stringify(this.s3));
    this.s3.listObjects((err, data) => {
      if (err) {
        console.log("Error", err);
      } else {
        console.log("Success", data);
      }
    });
  }
}