├── .forceignore ├── .gitignore ├── README.md ├── force-app └── main │ └── default │ ├── classes │ ├── FilePreviewController.cls │ ├── FilePreviewController.cls-meta.xml │ ├── FilePreviewControllerTest.cls │ └── FilePreviewControllerTest.cls-meta.xml │ └── lwc │ └── filePreview │ ├── filePreview.css │ ├── filePreview.html │ ├── filePreview.js │ └── filePreview.js-meta.xml ├── manifest └── package.xml └── sfdx-project.json /.forceignore: -------------------------------------------------------------------------------- 1 | **/jsconfig.json 2 | 3 | **/.eslintrc.json 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sfdx/ 7 | .localdevserver/ 8 | 9 | # LWC VSCode autocomplete 10 | **/lwc/jsconfig.json 11 | 12 | # LWC Jest coverage reports 13 | coverage/ 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Dependency directories 23 | node_modules/ 24 | 25 | # Eslint cache 26 | .eslintcache 27 | 28 | # MacOS system files 29 | .DS_Store 30 | 31 | # Windows system files 32 | Thumbs.db 33 | ehthumbs.db 34 | [Dd]esktop.ini 35 | $RECYCLE.BIN/ 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | Lightning Web Component that display related file information with preview, lazy loading, sort and filter abilities. 4 | 5 | ## Screenshots 6 | 7 | 8 | 9 | ## Enhancements 10 | 11 | - [ ] Store filters on the user records: ***In Progress*** 12 | - [ ] Better error handling 13 | - [x] [Add stencils (Skeleton loading) ](https://github.com/gavignon/lwc-file-preview/commit/ef8b79ad5585106b5feb509119328764a70c7ba1): **Prerequisite:** https://github.com/gavignon/lwc-stencil -------------------------------------------------------------------------------- /force-app/main/default/classes/FilePreviewController.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Gil Avignon 3 | * @date 12/05/2020 4 | * @description File Preview Component with filter and sort options 5 | */ 6 | public with sharing class FilePreviewController { 7 | private static Map mapConditionByFilter = new Map{ 8 | 'gt100KB' => 'ContentDocument.ContentSize >= ' + 100 * 1024, 9 | 'lt100KBgt10KB' => '(ContentDocument.ContentSize < ' + 100 * 1024 + ' AND ContentDocument.ContentSize > ' + 10 * 1024 + ')', 10 | 'lt10KB' => 'ContentDocument.ContentSize <= ' + 10 * 1024 11 | }; 12 | 13 | @AuraEnabled 14 | public static FilesInformation initFiles(Id recordId, List filters, Integer defaultLimit, String sortField, String sortOrder) { 15 | List contentDocumentLinks = new List(); 16 | defaultLimit = defaultLimit == null ? 3 : defaultLimit; 17 | 18 | String query = generateQuery(recordId, filters, defaultLimit, null, sortField, sortOrder); 19 | String countQuery = 'SELECT count() FROM ContentDocumentLink WHERE LinkedEntityId = ' + '\'' + recordId + '\''; 20 | countQuery += generateConditionString(filters); 21 | 22 | contentDocumentLinks = Database.query(query); 23 | FilesInformation fileInfo = new FilesInformation(); 24 | fileInfo.totalCount = Database.countQuery(countQuery); 25 | fileInfo.contentDocumentLinks = contentDocumentLinks; 26 | fileInfo.documentForceUrl = 'https://' + URL.getSalesforceBaseUrl().getHost().substringBefore('.') + '--c.documentforce.com'; 27 | 28 | return fileInfo; 29 | } 30 | 31 | @AuraEnabled 32 | public static List loadFiles(Id recordId, List filters, Integer defaultLimit, Integer offset, String sortField, String sortOrder) { 33 | 34 | String query = generateQuery(recordId, filters, defaultLimit, offset, sortField, sortOrder); 35 | System.debug(query); 36 | 37 | List contentDocumentLinks = Database.query(query); 38 | 39 | return contentDocumentLinks; 40 | } 41 | 42 | @AuraEnabled 43 | public static List queryFiles(Id recordId, List contentDocumentIds) { 44 | List contentDocumentLinks = new List(); 45 | 46 | contentDocumentLinks = [SELECT Id, ContentDocumentId, ContentDocument.LatestPublishedVersionId, ContentDocument.Title, ContentDocument.CreatedDate, ContentDocument.FileExtension, ContentDocument.ContentSize, ContentDocument.FileType 47 | FROM ContentDocumentLink 48 | WHERE LinkedEntityId = :recordId AND ContentDocumentId IN :contentDocumentIds]; 49 | 50 | return contentDocumentLinks; 51 | } 52 | 53 | private static String generateConditionString(List filters){ 54 | String conditionString = ''; 55 | if(filters != null && !filters.isEmpty()){ 56 | conditionString += ' AND ('; 57 | Boolean firstFilter = true; 58 | for(String filter : filters){ 59 | if(mapConditionByFilter.containsKey(filter)){ 60 | if(!firstFilter){ 61 | conditionString += ' OR '; 62 | } 63 | conditionString += mapConditionByFilter.get(filter); 64 | firstFilter = false; 65 | } 66 | } 67 | conditionString += ')'; 68 | } 69 | return conditionString; 70 | } 71 | 72 | private static String generateQuery(Id recordId, List filters, Integer defaultLimit, Integer offset, String sortField, String sortOrder){ 73 | String query = 'SELECT Id, ContentDocumentId, ContentDocument.LatestPublishedVersionId, ContentDocument.Title, ContentDocument.CreatedDate, ContentDocument.FileExtension, ContentDocument.ContentSize,ContentDocument.FileType '; 74 | query += 'FROM ContentDocumentLink '; 75 | query += 'WHERE LinkedEntityId = \'' + recordId + '\''; 76 | 77 | query += generateConditionString(filters); 78 | 79 | query += ' ORDER BY ' + sortField + ' ' + sortOrder; 80 | query += ' LIMIT ' + defaultLimit; 81 | 82 | if(offset != null){ 83 | query += ' OFFSET ' + offset; 84 | } 85 | 86 | return query; 87 | } 88 | 89 | public class FilesInformation{ 90 | @AuraEnabled 91 | public Integer totalCount; 92 | @AuraEnabled 93 | public String documentForceUrl; 94 | @AuraEnabled 95 | public List contentDocumentLinks; 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /force-app/main/default/classes/FilePreviewController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/FilePreviewControllerTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public with sharing class FilePreviewControllerTest { 3 | 4 | @testSetup 5 | static void setup() { 6 | Account acct = new Account(Name='TEST_ACCT'); 7 | insert acct; 8 | 9 | ContentVersion contentVersion = new ContentVersion( 10 | Title = 'Penguins', 11 | PathOnClient = 'Penguins.jpg', 12 | VersionData = Blob.valueOf('Test Content'), 13 | IsMajorVersion = true 14 | ); 15 | insert contentVersion; 16 | 17 | List documents = [SELECT Id, Title FROM ContentDocument]; 18 | 19 | //create ContentDocumentLink record 20 | ContentDocumentLink cdl = New ContentDocumentLink(); 21 | cdl.LinkedEntityId = acct.id; 22 | cdl.ContentDocumentId = documents[0].Id; 23 | cdl.shareType = 'V'; 24 | insert cdl; 25 | } 26 | 27 | @isTest 28 | static void testMethods() { 29 | Account acct = [SELECT Id FROM Account WHERE Name='TEST_ACCT' LIMIT 1]; 30 | List filters =new List{'gt100KB','lt100KBgt10KB','lt10KB'}; 31 | Integer defaultLimit = 3; 32 | Integer offset = 0; 33 | String sortField = 'ContentDocument.Title'; 34 | String sortOrder = 'DESC'; 35 | List ContentDocumentIds = new List(); 36 | for (ContentDocument cd : [SELECT Id FROM ContentDocument]) { 37 | ContentDocumentIds.add(cd.Id); 38 | } 39 | 40 | FilePreviewController.FilesInformation fileinfo = 41 | FilePreviewController.initFiles(acct.Id, filters, defaultLimit, sortField, sortOrder); 42 | List lf = 43 | FilePreviewController.loadFiles(acct.Id, filters, defaultLimit, offset, sortField, sortOrder); 44 | List qf = 45 | FilePreviewController.queryFiles(acct.Id, ContentDocumentIds); 46 | 47 | System.assertEquals(1, fileInfo.totalCount, 'TotalCount must be one.'); 48 | System.assertEquals(1, lf.size(), 'loadFiles must return one ContentDocumentLink.'); 49 | System.assertEquals(1, qf.size(), 'queryFiles must return one ContentDocumentLink.'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /force-app/main/default/classes/FilePreviewControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/filePreview/filePreview.css: -------------------------------------------------------------------------------- 1 | .thumbnailImg{ 2 | height: auto; 3 | min-width: 30px; 4 | max-width: 30px; 5 | max-height: 35px; 6 | } 7 | 8 | h2{ 9 | font-weight: bold; 10 | } 11 | 12 | .menu-actions{ 13 | right: 15px; 14 | top: 15px; 15 | } 16 | 17 | .sort{ 18 | font-size: 0.8em; 19 | border: 1px solid rgb(221, 219, 218); 20 | padding: 0 10px 0 10px; 21 | border-radius: .25rem; 22 | } 23 | 24 | .secondaryFields span:after { 25 | content: "\2022"; 26 | margin: 0 5px; 27 | } 28 | 29 | .secondaryFields span:last-child:after { 30 | content: ""; 31 | margin: 0; 32 | } 33 | 34 | lightning-file-upload [lightning-input_input-host]{ 35 | width: 100% !important; 36 | background: red; 37 | } 38 | 39 | lightning-file-upload lightning-input .slds-form-element__label{ 40 | display: none !important; 41 | } 42 | 43 | .slds-card__body{ 44 | margin-bottom: 0 !important; 45 | } 46 | 47 | .slds-file-selector.slds-file-selector_files{ 48 | width: 100%; 49 | height: 100px; 50 | } 51 | 52 | button:focus {outline:0;} 53 | 54 | button:disabled { 55 | cursor: pointer; 56 | } 57 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/filePreview/filePreview.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/filePreview/filePreview.js: -------------------------------------------------------------------------------- 1 | /* 2 | @author Gil Avignon 3 | @date 2020-05-10 4 | @description File Preview Client-side Controller 5 | */ 6 | import { LightningElement, track, wire, api } from 'lwc'; 7 | import { ShowToastEvent } from "lightning/platformShowToastEvent"; 8 | import { NavigationMixin } from 'lightning/navigation'; 9 | import initFiles from "@salesforce/apex/FilePreviewController.initFiles"; 10 | import queryFiles from "@salesforce/apex/FilePreviewController.queryFiles"; 11 | import loadFiles from "@salesforce/apex/FilePreviewController.loadFiles"; 12 | 13 | export default class FilePreview extends NavigationMixin(LightningElement) { 14 | @api recordId; 15 | @api defaultNbFileDisplayed; 16 | @api limitRows; 17 | 18 | @track moreLoaded = true; 19 | @track loaded = false; 20 | @track attachments; 21 | @track totalFiles; 22 | @track moreRecords; 23 | @track offset=0; 24 | @track fids = ''; 25 | @track sortIcon = 'utility:arrowdown'; 26 | @track sortOrder = 'DESC'; 27 | @track sortField = 'ContentDocument.CreatedDate'; 28 | @track disabled = true; 29 | @track filters = [ 30 | { 31 | 'id' : 'gt100KB', 32 | 'label' : '>= 100 KB', 33 | 'checked' : true 34 | }, 35 | { 36 | 'id' : 'lt100KBgt10KB', 37 | 'label' : '< 100 KB and > 10 KB', 38 | 'checked' : true 39 | }, 40 | { 41 | 'id' : 'lt10KB', 42 | 'label' : '<= 10 KB', 43 | 'checked' : true 44 | } 45 | ]; 46 | 47 | title; 48 | conditions; 49 | documentForceUrl; 50 | 51 | get DateSorted() { 52 | return this.sortField == 'ContentDocument.CreatedDate'; 53 | } 54 | get NameSorted() { 55 | return this.sortField == 'ContentDocument.Title'; 56 | } 57 | get SizeSorted() { 58 | return this.sortField == 'ContentDocument.ContentSize'; 59 | } 60 | get noRecords(){ 61 | return this.totalFiles == 0; 62 | } 63 | 64 | // Initialize component 65 | connectedCallback() { 66 | this.initRecords(); 67 | } 68 | 69 | initRecords(){ 70 | initFiles({ recordId: this.recordId, filters: this.conditions, defaultLimit: this.defaultNbFileDisplayed, sortField: this.sortField, sortOrder: this.sortOrder }) 71 | .then(result => { 72 | this.fids = ''; 73 | let listAttachments = new Array(); 74 | let contentDocumentLinks = result.contentDocumentLinks; 75 | this.documentForceUrl = result.documentForceUrl; 76 | 77 | for(var item of contentDocumentLinks){ 78 | listAttachments.push(this.calculateFileAttributes(item)); 79 | if (this.fids != '') this.fids += ','; 80 | this.fids += item.ContentDocumentId; 81 | } 82 | 83 | this.attachments = listAttachments; 84 | this.totalFiles = result.totalCount; 85 | this.moreRecords = result.totalCount > 3 ? true : false; 86 | 87 | let nbFiles = listAttachments.length; 88 | if (this.defaultNbFileDisplayed === undefined){ 89 | this.defaultNbFileDisplayed = 3; 90 | } 91 | if (this.limitRows === undefined){ 92 | this.limitRows = 3; 93 | } 94 | 95 | this.offset = this.defaultNbFileDisplayed; 96 | 97 | if(result.totalCount > this.defaultNbFileDisplayed){ 98 | nbFiles = this.defaultNbFileDisplayed + '+'; 99 | } 100 | this.title = 'Files (' + nbFiles + ')'; 101 | 102 | this.disabled = false; 103 | this.loaded = true; 104 | 105 | }) 106 | .catch(error => { 107 | this.showNotification("", "Error", "error"); 108 | }); 109 | } 110 | 111 | calculateFileAttributes(item){ 112 | let imageExtensions = ['png','jpg','gif']; 113 | let supportedIconExtensions = ['ai','attachment','audio','box_notes','csv','eps','excel','exe','flash','folder','gdoc','gdocs','gform','gpres','gsheet','html','image','keynote','library_folder','link','mp4','overlay','pack','pages','pdf','ppt','psd','quip_doc','quip_sheet','quip_slide','rtf','slide','stypi','txt','unknown','video','visio','webex','word','xml','zip']; 114 | item.src = this.documentForceUrl + '/sfc/servlet.shepherd/version/renditionDownload?rendition=THUMB120BY90&versionId=' + item.ContentDocument.LatestPublishedVersionId; 115 | item.size = this.formatBytes(item.ContentDocument.ContentSize, 2); 116 | item.icon = 'doctype:attachment'; 117 | 118 | let fileType = item.ContentDocument.FileType.toLowerCase(); 119 | if(imageExtensions.includes(fileType)){ 120 | item.icon = 'doctype:image'; 121 | }else{ 122 | if(supportedIconExtensions.includes(fileType)){ 123 | item.icon = 'doctype:' + fileType; 124 | } 125 | } 126 | 127 | return item; 128 | } 129 | 130 | // Manage Image Preview display if the image is loaded (so File rendition is generated) 131 | handleLoad(event){ 132 | let elementId = event.currentTarget.dataset.id; 133 | const eventElement = event.currentTarget; 134 | eventElement.classList.remove('slds-hide'); 135 | let dataId = 'lightning-icon[data-id="' + elementId + '"]'; 136 | 137 | this.template.querySelector(dataId).classList.add('slds-hide'); 138 | } 139 | 140 | openPreview(event){ 141 | let elementId = event.currentTarget.dataset.id; 142 | this[NavigationMixin.Navigate]({ 143 | type: 'standard__namedPage', 144 | attributes: { 145 | pageName: 'filePreview' 146 | }, 147 | state : { 148 | selectedRecordId: elementId, 149 | recordIds: this.fids 150 | } 151 | }) 152 | } 153 | 154 | openFileRelatedList(){ 155 | this[NavigationMixin.Navigate]({ 156 | type: 'standard__recordRelationshipPage', 157 | attributes: { 158 | recordId: this.recordId, 159 | objectApiName: 'Case', 160 | relationshipApiName: 'AttachedContentDocuments', 161 | actionName: 'view' 162 | }, 163 | }); 164 | } 165 | 166 | handleUploadFinished(event) { 167 | var self = this; 168 | // Get the list of uploaded files 169 | const uploadedFiles = event.detail.files; 170 | var contentDocumentIds = new Array(); 171 | for(var file of uploadedFiles){ 172 | contentDocumentIds.push(file.documentId); 173 | } 174 | queryFiles({ recordId: this.recordId, contentDocumentIds: contentDocumentIds }) 175 | .then(result => { 176 | for(var cdl of result){ 177 | self.attachments.unshift(self.calculateFileAttributes(cdl)); 178 | self.fileCreated = true; 179 | this.fids = cdl.ContentDocumentId + (this.fids=='' ? '' : ',' + this.fids); 180 | } 181 | 182 | self.updateCounters(result.length); 183 | this.totalFiles += result.length; 184 | }); 185 | 186 | } 187 | 188 | loadMore(){ 189 | this.moreLoaded = false; 190 | var self = this; 191 | loadFiles({ recordId: this.recordId, filters: this.conditions, defaultLimit: this.defaultNbFileDisplayed, offset: this.offset, sortField: this.sortField, sortOrder: this.sortOrder }) 192 | .then(result => { 193 | for(var cdl of result){ 194 | self.attachments.push(self.calculateFileAttributes(cdl)); 195 | self.fileCreated = true; 196 | if (this.fids != '') this.fids += ','; 197 | this.fids += cdl.ContentDocumentId; 198 | } 199 | 200 | self.updateCounters(result.length); 201 | self.moreLoaded = true; 202 | }); 203 | } 204 | 205 | updateCounters(recordCount){ 206 | this.offset += recordCount; 207 | this.moreRecords = this.offset < this.totalFiles; 208 | } 209 | 210 | handleFilterSelect (event) { 211 | const selectedItemValue = event.detail.value; 212 | const eventElement = event.currentTarget; 213 | let conditions = new Array(); 214 | for(var filter of this.filters){ 215 | if(filter.id === selectedItemValue){ 216 | filter.checked = !filter.checked; 217 | } 218 | if(filter.checked){ 219 | conditions.push(filter.id); 220 | } 221 | } 222 | // TODO: Manage no condition when they are all checked 223 | this.conditions = conditions; 224 | this.initRecords(); 225 | } 226 | 227 | handleSort(event){ 228 | this.disabled = true; 229 | 230 | let selectedValue = event.currentTarget.value; 231 | if(this.sortField === selectedValue){ 232 | this.toggleSortOrder(); 233 | } 234 | this.sortField = selectedValue; 235 | this.initRecords(); 236 | } 237 | 238 | toggleSortOrder(){ 239 | if(this.sortOrder == 'ASC'){ 240 | this.sortOrder = 'DESC'; 241 | this.sortIcon = 'utility:arrowdown'; 242 | }else{ 243 | this.sortOrder = 'ASC'; 244 | this.sortIcon = 'utility:arrowup'; 245 | } 246 | } 247 | 248 | formatBytes(bytes,decimals) { 249 | if(bytes == 0) return '0 Bytes'; 250 | var k = 1024, 251 | dm = decimals || 2, 252 | sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], 253 | i = Math.floor(Math.log(bytes) / Math.log(k)); 254 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 255 | } 256 | 257 | showNotification(title, message, variant) { 258 | const evt = new ShowToastEvent({ 259 | title: title, 260 | message: message, 261 | variant: variant 262 | }); 263 | this.dispatchEvent(evt); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/filePreview/filePreview.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | true 5 | 6 | lightning__AppPage 7 | lightning__RecordPage 8 | lightning__HomePage 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /manifest/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | ApexClass 6 | 7 | 8 | * 9 | LightningComponentBundle 10 | 11 | 48.0 12 | 13 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sfdcLoginUrl": "https://login.salesforce.com", 10 | "sourceApiVersion": "48.0" 11 | } --------------------------------------------------------------------------------