Friday, 27 March 2026

Export Data to XLSX in Salesforce (LWC + Apex) — Without Any Third-Party Library

Exporting data to Excel is a very common requirement in Salesforce. While many solutions rely on third-party libraries but using Apex and LWC we can generate a fully functional .xlsx file.

In this blog, you will learn:
  • How XLSX file structure works internally
  • How to generate Excel using Apex LWC
  • How to package it using native ZIP
  • How to download it using LWC

Key Concept

An .xlsx file is not a single file — it is actually a ZIP archive containing multiple XML files.

XML Files → ZIP Packaging → Base64 → Browser Download → Excel Opens

XLSX File Structure (Important)

.xlsx (ZIP)
│
├── [Content_Types].xml        → Defines file types
├── _rels/
│   └── .rels                 → Entry point
│
└── xl/
    ├── workbook.xml          → Sheet definitions
    ├── _rels/
    │   └── workbook.xml.rels → Sheet mapping
    │
    └── worksheets/
        └── sheet1.xml        → Actual data

How Excel Reads This

Excel → .rels → workbook.xml → relationships → sheet1.xml → render UI

Apex Code


public with sharing class ExportToXLSX {

    /**
     * Fetch Account records for UI preview and export
     * cacheable=true improves performance in LWC (uses client-side cache)
     */
    @AuraEnabled(cacheable=true)
    public static List<Account> getAccounts() {

        // Simple SOQL query with filter to avoid null-heavy data
        return [
            SELECT Name, BillingState, BillingCity, BillingCountry, Phone, Type
            FROM Account
            WHERE BillingCity != NULL AND BillingState != NULL
            LIMIT 100
        ];
    }

    /**
     * Main method to generate XLSX file
     * Returns Base64 string because LWC cannot directly handle Blob from Apex
     */
    @AuraEnabled
    public static String generateXlsx(List<Account> accounts) {

        // =====================================================
        // STEP 1: Build Sheet Data (Rows + Cells)
        // =====================================================

        // Each entry in this list represents one Excel row (<row>)
        List<String> rows = new List<String>();

        // -------------------------------
        // Header Row (Row 1)
        // -------------------------------
        rows.add(
            '<row r="1">' +  // r="1" = Excel row number

            // Each cell uses reference like A1, B1, etc.
            // t="inlineStr" means value stored directly inside XML
            cell('A1','Name') +
            cell('B1','Billing State') +
            cell('C1','Billing City') +
            cell('D1','Billing Country') +
            cell('E1','Phone') +
            cell('F1','Type') +

            '</row>'
        );

        Integer rowNum = 2; // Start from row 2 for actual data

        // -------------------------------
        // Data Rows
        // -------------------------------
        for (Account acc : accounts) {

            rows.add(
                '<row r="' + rowNum + '">' +

                // Order of cells defines Excel column order
                cell('A' + rowNum, acc.Name) +
                cell('B' + rowNum, acc.BillingState) +
                cell('C' + rowNum, acc.BillingCity) +
                cell('D' + rowNum, acc.BillingCountry) +
                cell('E' + rowNum, acc.Phone) +
                cell('F' + rowNum, acc.Type) +

                '</row>'
            );

            rowNum++;
        }

        // Combine all row XML into one string
        String sheetData = String.join(rows, '');

        // =====================================================
        // STEP 2: Worksheet XML (Actual Data Container)
        // =====================================================
        String worksheet =
            '<?xml version="1.0" encoding="UTF-8"?>' +

            // Root element for worksheet
            '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +

                // sheetData holds all rows
                '<sheetData>' + sheetData + '</sheetData>' +

            '</worksheet>';

        // =====================================================
        // STEP 3: Workbook XML (Defines Sheets)
        // =====================================================
        String workbook =
            '<?xml version="1.0" encoding="UTF-8"?>' +

            '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
            'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +

                '<sheets>' +

                    // r:id connects this sheet to workbook relationships
                    '<sheet name="Accounts" sheetId="1" r:id="rId1"/>' +

                '</sheets>' +

            '</workbook>';

        // =====================================================
        // STEP 4: Workbook Relationships
        // Maps sheet reference → actual file location
        // =====================================================
        String workbookRels =
            '<?xml version="1.0" encoding="UTF-8"?>' +

            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +

                '<Relationship Id="rId1" ' +
                'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' +
                'Target="worksheets/sheet1.xml"/>' +

            '</Relationships>';

        // =====================================================
        // STEP 5: Root Relationships (Entry Point)
        // =====================================================
        String rootRels =
            '<?xml version="1.0" encoding="UTF-8"?>' +

            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +

                // Points Excel to workbook.xml
                '<Relationship Id="rId1" ' +
                'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' +
                'Target="xl/workbook.xml"/>' +

            '</Relationships>';

        // =====================================================
        // STEP 6: Content Types (Mandatory)
        // =====================================================
        String contentTypes =
            '<?xml version="1.0" encoding="UTF-8"?>' +

            '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +

                '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
                '<Default Extension="xml" ContentType="application/xml"/>' +

                '<Override PartName="/xl/workbook.xml" ' +
                'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +

                '<Override PartName="/xl/worksheets/sheet1.xml" ' +
                'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +

            '</Types>';

        // =====================================================
        // STEP 7: ZIP Packaging (Creates XLSX)
        // =====================================================
        Compression.ZipWriter writer = new Compression.ZipWriter();

        // IMPORTANT: File structure must match Excel specification exactly
        writer.addEntry('[Content_Types].xml', Blob.valueOf(contentTypes));
        writer.addEntry('_rels/.rels', Blob.valueOf(rootRels));
        writer.addEntry('xl/workbook.xml', Blob.valueOf(workbook));
        writer.addEntry('xl/_rels/workbook.xml.rels', Blob.valueOf(workbookRels));
        writer.addEntry('xl/worksheets/sheet1.xml', Blob.valueOf(worksheet));

        // Final XLSX file (ZIP archive)
        Blob zipBlob = writer.getArchive();

        // Return Base64 string to LWC
        return EncodingUtil.base64Encode(zipBlob);
    }

    /**
     * Helper method to create a cell
     */
    private static String cell(String ref, String value) {

        return '<c r="' + ref + '" t="inlineStr">' +
                    '<is>' +
                        '<t>' + escape(value) + '</t>' +
                    '</is>' +
               '</c>';
    }

    /**
     * Escape XML special characters
     * Prevents file corruption
     */
    private static String escape(String val) {

        if (val == null) return '';

        return val.replace('&','&')
                  .replace('<','&lt;')
                  .replace('>','&gt;');
    }
}


LWC JavaScript


import { LightningElement, wire, track } from 'lwc';

// Import Apex methods
import getAccounts from '@salesforce/apex/ExportToXLSX.getAccounts';
import generateXlsx from '@salesforce/apex/ExportToXLSX.generateXlsx';

export default class ExportToXLSX extends LightningElement {

    // Reactive properties
    @track accounts = [];     // Holds Account data
    @track isLoading = false; // Controls spinner UI
    @track error;             // Stores error if any

    // Define datatable columns
    columns = [
        { label: 'Name', fieldName: 'Name' },
        { label: 'State', fieldName: 'BillingState' },
        { label: 'City', fieldName: 'BillingCity' },
        { label: 'Country', fieldName: 'BillingCountry' },
        { label: 'Phone', fieldName: 'Phone' },
        { label: 'Type', fieldName: 'Type' }
    ];

    /**
     * Fetch Accounts using @wire (reactive + cacheable)
     */
    @wire(getAccounts)
    wiredAccounts({ error, data }) {

        if (data) {
            this.accounts = data;   // Assign data to UI
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.accounts = [];
            console.error(error);
        }
    }

    /**
     * Handle Download Button Click
     */
    async handleDownload() {

        // Prevent export if no data
        if (!this.accounts || this.accounts.length === 0) {
            alert('No data to export');
            return;
        }

        this.isLoading = true;

        try {
            // Call Apex to generate XLSX
            const base64 = await generateXlsx({
                accounts: this.accounts
            });

            // Convert Base64 → downloadable file
            this.downloadFile(base64, 'Accounts.xlsx');

        } catch (err) {
            console.error('Download error:', err);
        } finally {
            this.isLoading = false;
        }
    }

    /**
     * Convert Base64 → Blob → Trigger Download
     */
    downloadFile(base64, fileName) {

        // Decode Base64 string into binary string
        const byteCharacters = atob(base64);

        // Convert binary string → byte array
        const byteNumbers = new Array(byteCharacters.length);

        for (let i = 0; i < byteCharacters.length; i++) {
            byteNumbers[i] = byteCharacters.charCodeAt(i);
        }

        // Create typed array
        const byteArray = new Uint8Array(byteNumbers);

        // Create Blob (file object)
        const blob = new Blob([byteArray], {
            type: 'application/octet-stream'
        });

        // Create temporary download link
        const url = URL.createObjectURL(blob);

        const link = document.createElement('a');
        link.href = url;
        link.download = fileName;

        // Trigger download
        document.body.appendChild(link);
        link.click();

        // Cleanup
        document.body.removeChild(link);
        URL.revokeObjectURL(url);
    }
}

LWC HTML


<template>

    <!-- Card container -->
    <lightning-card title="Account Excel Download" icon-name="standard:account">

        <div class="slds-m-around_medium">

            <!-- Download Button -->
            <!-- Disabled when file generation is in progress -->
            <lightning-button 
                label="Download Excel (.xlsx)"
                variant="brand"
                onclick={handleDownload}
                disabled={isLoading}>
            </lightning-button>

            <!-- Spinner shown during processing -->
            <template if:true={isLoading}>
                <lightning-spinner 
                    alternative-text="Generating Excel..." 
                    size="medium">
                </lightning-spinner>
            </template>

            <!-- Error display -->
            <template if:true={error}>
                <div class="slds-text-color_error slds-m-top_small">
                    Error loading data
                </div>
            </template>

            <!-- Data preview -->
            <template if:true={accounts}>

                <!-- Record count -->
                <div class="slds-m-top_medium">
                    <p><b>Total Records:</b> {accounts.length}</p>
                </div>

                <!-- Datatable -->
                <!-- Used only for preview, not for export -->
                <lightning-datatable
                    key-field="Id"
                    data={accounts}
                    columns={columns}
                    hide-checkbox-column>
                </lightning-datatable>

            </template>

        </div>

    </lightning-card>

</template>

Best Practices

  • Always escape XML characters (&, <, >)
  • Validate structure carefully
  • Keep dataset small (< 5k records ideally)
  • Use batch/async for large exports

Debug Tip

Rename .xlsx → .zip → Extract → Inspect XML files

Conclusion

Using native Apex and LWC, you can generate Excel files without any third-party libraries. This approach is lightweight, secure, and ideal for simple export use cases.

No external dependency required 🎉

Code has been kept @ https://github.com/AvijitGoraiGitHub/Export-Data-to-XLSX-in-Salesforce

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);
      }
    });
  }
}