Sunday, May 16, 2021

Building Lightning File Uploader with files as Thumbnail

 

Motivation behind this


I thought of building file uploader component with files related list displayed as thumbnail and also trying to implement event handling framework where child-to-parent and parent-to-child can easily be understandable by Lightning Web Component developer.

I have planned to leverage lightning-file-upload base component instead of lightning-input type="file" which drives me to build this poc where as thumbnail display is icing sugar on a cake.

Let's get started.

Use Case


Business has a requirement to upload multiple files (with huge file size) and wants to see uploaded files  as thumbnail with relevant file information.

Developer wants to leverage Lightning base component bypassing pain on writing code for uploading files.

Possible End Results


The component will look like this below:


File uploader component will work as demonstrated in the following video:




Initial R&D which has thrown away


To start with a poc, I had created one component which contains both file uploader and attachment related list, as because my initial goal was to display attachments as thumbnail. The files display part had been achieved as shown in picture.

Problem lies on using uploadfinished custom event handler of lightning-file-upload component. The handleUploadFinished event handler method doesn't allow to call any methods in the same class so that I can refresh the related list with newly uploaded files. As, the event doesn't support bubbling, cancelling and composing.

So, I have started coming up with following solution approach.

Solution Approach


Framed the following design:
  • There will be a parent container component attachmentComponentThumbnail which will contain 2 children attachmentUploadComponent and attachmentRelatedList components and child components can also be used individually if necessary.
  • Parent component will not just behave as a container rather it will also act as business delegate. For example, when attachment-upload component finishes its part of uploading files, it won't directly call relatedList component for refreshing the file list.
Rather, parent container component will take care whom to call for refreshing the list. Because parent knows the responsibilities for its children better. That's the beauty.


Flow Diagram on Event Propagations


Following flow diagram will help to understand event propagation.



Lets see the individual components.

attachmentUploadComponent

This component will look like this as shown in the flow diagram.

HTML will like below.

<template>
    <div class="slds-p-around_medium">
        <lightning-file-upload
                label="Attach your file(s) here..."
                name="fileUploader"
                record-id={recordId}
                onuploadfinished={handleUploadFinished}
                multiple>
        </lightning-file-upload>
      </div>
</template>

attachmentUploadComponent.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
import { LightningElement,api } from 'lwc';
import {ShowToastEvent} from 'lightning/platformShowToastEvent';

export default class AttachmentUploadComponent extends LightningElement {
    @api recordId;
    
    //This method fires after files got uploaded
    handleUploadFinished(event) {
        const linkedinEntityId = this.recordId;    
        const uploadedFiles = event.detail.files;
        //show success toast message        
        const evt = new ShowToastEvent({
            title: 'File Upload Status...',
            message: uploadedFiles.length + 'file(s) uploaded successfully.',
            variant: 'success',
        });
        this.dispatchEvent(evt);

        /*dispatch this event to the parent, so that parent will take care to delegate this to 
         attachmentRelatedList component for refreshing the list with currently loaded files.
        */
         const evtCustomEvent = new CustomEvent('refreshlist', {   
            detail: {linkedEntityId}
            });
        this.dispatchEvent(evtCustomEvent);
    }
}

Few notable points in this js file:
  • displaying toast message on successful file upload.
  • propagating refreshlist event to parent component

attachmentRelatedList component


HTML looks like below:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!--
  @name              : attachmentRelatedList.html
  @description       : This component acts as Related List Component which can be used alone or
                       inside any other component (attachmentComponentThumbnail)
  @author            : Santanu Boral  
-->
<template>
  <lightning-card  title={relatedListTitle}>    
    <div class="slds-grid slds-wrap slds-p-around_medium">    
      <template if:true={results}>                
        <template for:each={results} for:item="file">  
          <div class= "slds-col slds-size_1-of-2 slds-p-around_medium" key={file.fileId} >                    
            <div style="width:14rem" key={file.fileId}>
              <div class="slds-file slds-file_card slds-has-title">
                <figure>
                  <a href={file.viewUrl} class="slds-file__crop" target="_blank">
                    <img src={file.thumbnailPath} alt={file.fileName} />
                  </a>
                  <figcaption class="slds-file__title slds-file__title_card">                      
                    <div class="slds-media__body">
                      <p class="slds-truncate">
                          <lightning-formatted-url value={file.downloadUrl} 
                            tooltip={file.filePath} 
                            label={file.fileName} 
                            target="_blank">
                        </lightning-formatted-url>
                      </p>
                      <p> {file.fileDate} . {file.fileSize} . {file.fileExtn} </p>
                    </div>                      
                  </figcaption>
                </figure>
              </div>
            </div>
          </div>
        </template>
      </template>     
    </div>      
  </lightning-card>
</template>

I have used Lightning Design System for Files to develop this component

js file


 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
import { LightningElement, api, track } from 'lwc';
import retrieveFiles from '@salesforce/apex/AttachmentController.retrieveFiles';

export default class AttachmentRelatedList extends LightningElement {
    @api recordId; //recordId of the record page where it is being placed
    @track results = []; //array to prepare the thumbnail type file to be shown   
    relatedListTitle; //used for preparing title e.g. Attachments (3)

    //This method is called from parent component after receiving event on files got uploaded.
    @api
    refreshList(linkedEntityId) {
        this.fetchFileData(linkedEntityId);
    }

    //if the component is used alone then it will help to retrieve file details
    connectedCallback(){
        this.fetchFileData(this.recordId);
    }

    //It fetches the files and related information
    fetchFileData(parentRecordId){
        retrieveFiles({recordId: parentRecordId})
        .then(data=> this.prepareFileRows(data))        
        .catch(err => { console.log(err) });
    }

    /*
        This method is used to prepare file data to be displayed as thumbnail with other
        information like size, format, date of loading, viewURL, downloadURL
    */
    prepareFileRows(data){
        if(data){
            //prepare title            
            this.relatedListTitle = 'Attachments (' + data.length + ')'; 
            this.results = []; //initialize the array
            //extract file data and prepare results array with converted information
            data.map(element=>{
                let fSize = this.formatFileSize(element.ContentSize);
                let fDate = this.formatDateString((element.CreatedDate).slice(0, 10));
                this.results = 
                [...this.results,
                    {
                        fileId: element.Id,
                        fileName:element.Title,
                        filePath: element.PathOnClient,
                        fileType:element.FileType,
                        fileExtn: element.FileExtension,
                        fileSize: fSize,
                        fileDate: fDate,
                        thumbnailPath: '/sfc/servlet.shepherd/version/renditionDownload?rendition=thumb120by90&versionId=' 
                                        + element.Id + '&operationContext=CHATTER&contentId=' + element.ContentDocumentId,
                        downloadUrl: '/sfc/servlet.shepherd/document/download/' + element.ContentDocumentId,
                        viewUrl: '/lightning/r/ContentDocument/' + element.ContentDocumentId + '/view'
                    }
                ]                
            }); 
        }
    }

    //This method is used to prepare date as May 16, 2021 which usally comes as YYYY-MM-DD format
    formatDateString(dateStr){
        const dt = new Date(dateStr);
        const year = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(dt);
        const month = new Intl.DateTimeFormat('en', { month: 'short' }).format(dt);
        const day = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(dt);
        return month + ' ' + day + ', ' + year;
    }

    //This method prepare file size to be displayed in MB or KB
    formatFileSize(fileSize){
        let f = Math.abs(fileSize/1024);        
        return (f > 1024? Math.abs(fileSize/(1024 * 1024)).toFixed(2) + ' MB' 
                : Math.round(f) + ' KB');
    }
}

Few notable points in this js file:
  • preparing thumbnail URL which is in this format where versionId is ContentVersionId and contentId is ContentDocumentId

/sfc/servlet.shepherd/version/renditionDownload?rendition=thumb120by90&versionId=0682v00000JltJK&operationContext=CHATTER&contentId=05T2v00001lGQxa
  • preparing download URL as follows
'/sfc/servlet.shepherd/document/download/' + element.ContentDocumentId
  • Conversion of date format to be displayed as May 21, 2021 and fize size to be displayed in MB and KB
  • When we click on preview it will open new window to display file details
  • Clicking on fileURL, it will download the file.
  • As this component can be used independently, so connectedCallback is used to call retrieveFiles method of Apex controller implicitly.
  • There is no async-await has been used as ESLint will complain rather used promise.
  • refreshList method is public so that it can called from outside, here Parent component will call this method.
  • Error handling is omitted for sake of brevity.
AttachmentController.cls

This controller has method method retrieveFiles based on LinkedEntityId i.e. recordId of the record where this component is placed.
Notice that, usually we make method as (cacheable=true), here it is made as false.
If we can't do this then after file upload, if we try to refresh lists then it will give previous files which has been fetched earlier.

This was a bottleneck during refreshing the list.

 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
/**
 * name               : AttachmentController
 * @description       : This class handles for attachment related information collecting
 * @author            : Santanu Boral 
**/
public with sharing class AttachmentController {
    
    //cacheable is omitted otherwise we will not get refreshed data upon uploading
    @AuraEnabled
    public static List<ContentVersion> retrieveFiles(String recordId){
        //get records from ContentDocumentLink based on LinkedEntityId
                List<ContentDocumentLink> cdlList = [SELECT ContentDocumentId  
                FROM ContentDocumentLink 
                WHERE LinkedEntityId =: recordId];

        List<Id> cdIds = new List<Id>(); //ContentDocumentIds
        for(ContentDocumentLink obj: cdlList){
            cdIds.add(obj.ContentDocumentId);
        }
        //get records from ContentVersion based on ContentDocumentIds
        List<ContentVersion> cvList = [SELECT Id, ContentDocumentId, ContentUrl, 
                VersionNumber, Title, PathOnClient, 
                FileType, FileExtension, ContentSize,
                CreatedDate 
            FROM ContentVersion 
            WHERE ContentDocumentId IN:cdIds];
        return cvList;        
    }    
}

Lets move on to Parent Component which holds above two.

attachmentComponentThumbnail

HTML looks like below:

 onrefreshlist event handler captures the event raised by attachmentUpload component. And record-id is used to pass recordId to the children.

<template>
    <lightning-card title="File Uploader">
      <c-attachment-upload-component 
                  onrefreshlist={delegateRefreshingAttachmentList} 
                  record-id={recordId}>
      </c-attachment-upload-component>
      <c-attachment-related-list record-id={recordId}></c-attachment-related-list>      
    </lightning-card>
</template>

js file

When it captures the event, it extracts the arguments and finally search for attachmentRelatedList component and calls its refreshList public method. That's why this component is used as BusinessDelegate. Not just a container.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { LightningElement,api} from 'lwc';
export default class AttachmentComponentThumbnail extends LightningElement {   
    @api recordId;   
    
    /*
        This method is handler for refreshlist event which is raised by 
        attachment-upload-component. It will search for attachment-related-list in the DOM
        and call refreshList method passing linkedEntityId as argument
    */
    delegateRefreshingAttachmentList(event) {                
        let args = JSON.parse(JSON.stringify(event.detail));
        let linkedEntityId = args.linkedinEntityId;        
        this.template.querySelector('c-attachment-related-list').refreshList(linkedEntityId);
    }
}

Create meta-files where this component to be exposed. You might have to take care additional things for Community implementations.

Coding is done, place this component to the record detail page and use it.

I have tested with uploading 215 MB single file and it worked.

Finally, thanks for your patience to read this.

References


Further Reading


4 comments:

  1. Hi Santanu,

    Thanks for sharing.

    Please let me know if this will form community as well when site guest users uploading the files.

    ReplyDelete
  2. To be honest, I don't need this feature but I want to say a big thanks for the documentation provided.
    I'll keep it in bookmark as a POC of documentation and I'll share with no hesitation as it can really help people understand how to build lwc , how to handle limitation and so on.

    So thanks a lot for sharing this solution (quite sure it will help many people) and I'm also sure your document will help a lot newbie lwc developper :)

    ReplyDelete