├── .eslintignore
├── .forceignore
├── .gitignore
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── config
└── project-scratch-def.json
├── force-app
└── main
│ └── default
│ ├── classes
│ ├── FileCollection.cls
│ ├── FileCollection.cls-meta.xml
│ ├── FileOperationsController.cls
│ └── FileOperationsController.cls-meta.xml
│ └── lwc
│ ├── .eslintrc.json
│ └── flowFileUpload
│ ├── flowFileUpload.css
│ ├── flowFileUpload.html
│ ├── flowFileUpload.js
│ ├── flowFileUpload.js-meta.xml
│ └── helpers
│ └── helpers.js
├── package-lock.json
├── package.json
└── sfdx-project.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/lwc/**/*.css
2 | **/lwc/**/*.html
3 | **/lwc/**/*.json
4 | **/lwc/**/*.svg
5 | **/lwc/**/*.xml
6 | .sfdx
--------------------------------------------------------------------------------
/.forceignore:
--------------------------------------------------------------------------------
1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
3 | #
4 |
5 | package.xml
6 |
7 | # LWC configuration files
8 | **/jsconfig.json
9 | **/.eslintrc.json
10 |
11 | # LWC Jest
12 | **/__tests__/**
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # List files or directories below to ignore them when running prettier
2 | # More information: https://prettier.io/docs/en/ignore.html
3 | #
4 |
5 | .localdevserver
6 | .sfdx
7 |
8 | coverage/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "overrides": [
4 | {
5 | "files": "**/lwc/**/*.html",
6 | "options": { "parser": "lwc" }
7 | },
8 | {
9 | "files": "*.{cmp,page,component}",
10 | "options": { "parser": "html" }
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Suraj Pillai
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Salesforce App
2 |
3 | This is an LWC for uploading files that tries to mimick https://github.com/Yuvaleros/material-ui-dropzone. It supports a limited set of features, comparitively speaking, and has been primarily designed to be used in flow screens. The accompanying invocable method lets you create a content document. content version and content document link, as needed.
4 |
--------------------------------------------------------------------------------
/config/project-scratch-def.json:
--------------------------------------------------------------------------------
1 | {
2 | "orgName": "demo company",
3 | "edition": "Developer",
4 | "features": []
5 | }
6 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/FileCollection.cls:
--------------------------------------------------------------------------------
1 | public with sharing class FileCollection {
2 | @InvocableVariable
3 | public ContentVersion[] files;
4 |
5 | @InvocableVariable
6 | public Id relatedRecordId;
7 | }
8 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/FileCollection.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 48.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/FileOperationsController.cls:
--------------------------------------------------------------------------------
1 | /**
2 | * @description contains operations realted to file upload lwc
3 | */
4 | public with sharing class FileOperationsController {
5 | @InvocableMethod(
6 | label='Create Content'
7 | description='Create Content Document/Version and link it with a record'
8 | )
9 | public static List createContentDocumentLink(
10 | List fileCollectionList
11 | ) {
12 | List versionsToLink = new List();
13 | ContentVersion[] versionsToCreate = new List{};
14 | List versionRecordsToLink = new List();
15 |
16 | for (FileCollection fc : fileCollectionList) {
17 | for (ContentVersion cv : fc.files) {
18 | if (cv.ContentDocumentId == null && fc.relatedRecordId != null) {
19 | versionsToLink.add(new LinkWrapper(cv, fc.relatedRecordId));
20 | versionRecordsToLink.add(cv);
21 | }
22 | versionsToCreate.add(cv);
23 | }
24 | }
25 | if (versionsToCreate.size() > 0) {
26 | insert versionsToCreate;
27 | }
28 | ContentDocumentLink[] links = new List{};
29 | Set documentIdsAdded = new Set();
30 | Map cvMap = new Map(
31 | [
32 | SELECT ContentDocumentId
33 | FROM ContentVersion
34 | WHERE Id = :versionRecordsToLink
35 | ]
36 | );
37 | for (LinkWrapper lw : versionsToLink) {
38 | ContentDocumentLink link = new ContentDocumentLink();
39 | link.ContentDocumentId = cvMap.get(lw.cv.Id).ContentDocumentId;
40 | link.LinkedEntityId = lw.relatedRecordId;
41 | if (!documentIdsAdded.contains(link.ContentDocumentId)) {
42 | links.add(link);
43 | documentIdsAdded.add(link.ContentDocumentId);
44 | }
45 | }
46 | if (links.size() > 0) {
47 | insert links;
48 | }
49 | return new List(new Map(versionsToCreate).keySet());
50 | }
51 |
52 | private class LinkWrapper {
53 | public ContentVersion cv;
54 | public Id relatedRecordId;
55 | public LinkWrapper(ContentVersion cv, Id relatedRecordId) {
56 | this.cv = cv;
57 | this.relatedRecordId = relatedRecordId;
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/FileOperationsController.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 48.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@salesforce/eslint-config-lwc/recommended"]
3 | }
4 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/flowFileUpload/flowFileUpload.css:
--------------------------------------------------------------------------------
1 | @keyframes progress {
2 | 0% {
3 | background-position: 0 0;
4 | }
5 | 100% {
6 | background-position: -70px 0;
7 | }
8 | }
9 | .dropzone {
10 | position: relative;
11 | width: 100%;
12 | min-height: 250px;
13 | background-color: #f0f0f0;
14 | border: dashed;
15 | border-color: #c8c8c8;
16 | cursor: pointer;
17 | box-sizing: border-box;
18 | }
19 | .stripes {
20 | border: solid;
21 | background-image: repeating-linear-gradient(
22 | -45deg,
23 | #f0f0f0,
24 | #f0f0f0 25px,
25 | #c8c8c8 25px,
26 | #c8c8c8 50px
27 | );
28 | animation: progress 2s linear infinite !important;
29 | background-size: 150% 100%;
30 | }
31 | .rejectstripes {
32 | border: solid;
33 | background-image: repeating-linear-gradient(
34 | -45deg,
35 | #fc8785,
36 | #fc8785 25px,
37 | #f4231f 25px,
38 | #f4231f 50px
39 | );
40 | animation: progress 2s linear infinite !important;
41 | background-size: 150% 100%;
42 | }
43 | .dropzonetextstyle {
44 | text-align: center;
45 | }
46 | .uploadiconsize {
47 | width: 51;
48 | height: 51;
49 | color: #909090;
50 | }
51 | .dropzoneparagraph {
52 | font-size: 24;
53 | }
54 | .message {
55 | font: 15px bold;
56 | margin-top: 8px;
57 | }
58 | .success {
59 | color: green;
60 | }
61 | .error {
62 | color: red;
63 | }
64 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/flowFileUpload/flowFileUpload.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 | {snackbarMessage}
13 |
14 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/flowFileUpload/flowFileUpload.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, api } from "lwc";
2 | import { createFileFromUrl, accepts } from "./helpers/helpers.js";
3 | import { createRecord } from "lightning/uiRecordApi";
4 | import { ShowToastEvent } from "lightning/platformShowToastEvent";
5 | import { FlowAttributeChangeEvent } from "lightning/flowSupport";
6 |
7 | const BASE64REGEXP = new RegExp(/^data(.*)base64,/);
8 |
9 | export default class FlowFileUpload extends LightningElement {
10 | fileObjects = [];
11 | openSnackbar = false;
12 | snackbarMessage = "";
13 | snackbarVariant = "success";
14 | @api relatedRecordId = "0010t00001NKoQrAAL";
15 | @api contentDocumentId;
16 | @api multiple = false;
17 | @api acceptedFileTypes = ["image/png", "application/pdf"];
18 | @api dropzoneText = "";
19 | @api initialFiles = [];
20 | @api filesLimit = 1;
21 | @api maxFileSize = 2048;
22 | dropRejected = false;
23 | _isDragActive = false;
24 |
25 | @api
26 | get contentVersions() {
27 | return this.fileObjects.map(f => {
28 | return this._createPayload(f);
29 | });
30 | }
31 |
32 | get messageClassName() {
33 | return "message " + (this.dropRejected ? "error" : "success");
34 | }
35 |
36 | getFileLimitExceedMessage(filesLimit) {
37 | return `Maximum allowed number of files exceeded. Only ${filesLimit} allowed`;
38 | }
39 |
40 | getFileAddedMessage(fileName) {
41 | return `File ${fileName} successfully added.`;
42 | }
43 |
44 | getFileRemovedMessage(fileName) {
45 | return `File ${fileName} removed.`;
46 | }
47 |
48 | getDropRejectMessage(rejectedFile, acceptedFiles, maxFileSize) {
49 | let message = `File ${rejectedFile.name} was rejected. `;
50 | if (!acceptedFiles.includes(rejectedFile.type)) {
51 | message += "File type not supported. ";
52 | }
53 | if (rejectedFile.size > maxFileSize) {
54 | message += "File is too big. Size limit is " + maxFileSize + ". ";
55 | }
56 | return message;
57 | }
58 |
59 | connectedCallback() {
60 | this.filesArray(this.initialFiles);
61 | }
62 |
63 | async filesArray(urls) {
64 | try {
65 | for (const url of urls) {
66 | /*eslint-disable-next-line no-await-in-loop*/
67 | const file = await createFileFromUrl(url);
68 | const reader = new FileReader();
69 | reader.onload = () => {
70 | this.fileObjects = this.fileObjects.concat({
71 | file: file,
72 | data: reader.result
73 | });
74 | };
75 | reader.readAsDataURL(file);
76 | }
77 | } catch (err) {
78 | console.log(err);
79 | }
80 | }
81 |
82 | _containsFiles(event) {
83 | if (event.dataTransfer && event.dataTransfer.types) {
84 | return Array.from(event.dataTransfer.types).indexOf("Files") > -1;
85 | }
86 | return false;
87 | }
88 |
89 | handleDrag(event) {
90 | event.preventDefault();
91 | //this._isDragActive = true;
92 |
93 | if (event.dataTransfer) {
94 | try {
95 | event.dataTransfer.dropEffect = "copy";
96 | } catch (e) {} /* eslint-disable-line no-empty */
97 | }
98 | }
99 |
100 | handleSubmit() {
101 | this.fileObjects.forEach(f => {
102 | let payload = {
103 | apiName: "ContentVersion",
104 | fields: this._createPayload(f)
105 | };
106 | createRecord(payload)
107 | .then(() => {
108 | this.dispatchEvent(
109 | new ShowToastEvent({
110 | variant: "success",
111 | message: "File " + f.file.name + " uploaded successfully"
112 | })
113 | );
114 | return Promise.resolve(1);
115 | })
116 | .then(() => {
117 | if (!this.contentDocumentId && this.relatedRecordId) {
118 | this.dispatchEvent(
119 | new ShowToastEvent({
120 | variant: "success",
121 | message: `Content Document Link created`
122 | })
123 | );
124 | }
125 | })
126 | .catch(err => {
127 | this.dispatchEvent(
128 | new ShowToastEvent({
129 | variant: "error",
130 | message: `An error occurred with file ${f.file.name}: ${err.body.message}`
131 | })
132 | );
133 | });
134 | });
135 | }
136 |
137 | _createPayload(fileObject) {
138 | const fileData = {
139 | Title: fileObject.file.name,
140 | PathOnClient: fileObject.file.name,
141 | VersionData: fileObject.data.replace(BASE64REGEXP, "")
142 | };
143 | if (this.contentDocumentId) {
144 | fileData.ContentDocumentId = this.contentDocumentId;
145 | }
146 | return fileData;
147 | }
148 |
149 | _validate(files) {
150 | const acceptedFiles = [];
151 | const rejectedFiles = [];
152 |
153 | files.forEach(file => {
154 | if (accepts(file, this.acceptedFileTypes)) {
155 | acceptedFiles.push(file);
156 | } else {
157 | rejectedFiles.push(file);
158 | }
159 | });
160 |
161 | if (!this.multiple && acceptedFiles.length > 1) {
162 | rejectedFiles.push(...acceptedFiles.splice(0)); // Reject everything and empty accepted files
163 | }
164 | return { acceptedFiles, rejectedFiles };
165 | }
166 |
167 | get classNames() {
168 | return `dropzone ${this._isDragActive &&
169 | (this.dropRejected ? "rejectstripes" : "stripes")}`;
170 | }
171 |
172 | _getFilesFromEvent(event) {
173 | return Array.from(event.dataTransfer.items)
174 | .filter(i => i.kind === "file")
175 | .map(i => i.getAsFile());
176 | }
177 |
178 | handleDragEnter(event) {
179 | this._isDragActive = true;
180 | if (!this._containsFiles(event)) {
181 | this.dropRejected = true;
182 | return false;
183 | }
184 | this.dropRejected = false;
185 | return true;
186 | }
187 |
188 | handleDragLeave() {
189 | this._isDragActive = false;
190 | }
191 |
192 | handleDrop(event) {
193 | //{{{
194 | //const _this = this;
195 | this._isDragActive = false;
196 | let files = this._getFilesFromEvent(event);
197 | event.preventDefault();
198 | if (
199 | this.filesLimit > 1 &&
200 | this.fileObjects.length + files.length > this.filesLimit
201 | ) {
202 | this.openSnackBar = true;
203 | this.snackbarMessage = this.getFileLimitExceedMessage(this.filesLimit);
204 | this.snackbarVariant = "error";
205 | this.dropRejected = true;
206 | } else {
207 | let results = this._validate(files);
208 | if (results.rejectedFiles.length > 0) {
209 | return this.handleDropRejected(results.rejectedFiles, event);
210 | }
211 | this.dropRejected = false;
212 | let count = 0;
213 | let message = "";
214 | if (!Array.isArray(files)) files = [files];
215 |
216 | files.forEach(file => {
217 | const reader = new FileReader();
218 | reader.onload = () => {
219 | this.fileObjects =
220 | this.filesLimit <= 1
221 | ? [
222 | {
223 | file: file,
224 | data: reader.result
225 | }
226 | ]
227 | : this.fileObjects.concat({
228 | file: file,
229 | data: reader.result
230 | });
231 | /**
232 | if (this.onChange) {
233 | this.onChange(_this.state.fileObjects.map((fileObject) => fileObject.file));
234 | }
235 | if (this.onDrop) {
236 | this.onDrop(file);
237 | } **/
238 | message += this.getFileAddedMessage(file.name);
239 | count++; // we cannot rely on the index because this is asynchronous
240 | if (count === files.length) {
241 | // display message when the last one fires
242 | this.openSnackBar = true;
243 | this.snackbarMessage = message;
244 | this.snackbarVariant = "success";
245 | this._fireAttributeChangeEvent();
246 | }
247 | };
248 | reader.readAsDataURL(file);
249 | });
250 | }
251 | return null;
252 | } //}}}
253 |
254 | _fireAttributeChangeEvent() {
255 | const attributeChangeEvent = new FlowAttributeChangeEvent(
256 | "contentVersions",
257 | this.contentVersions
258 | );
259 | this.dispatchEvent(attributeChangeEvent);
260 | }
261 |
262 | handleRemove = fileIndex => event => {
263 | event.stopPropagation();
264 | const fileObjects = this.fileObjects;
265 | const file = fileObjects.filter((fileObject, i) => {
266 | return i === fileIndex;
267 | })[0].file;
268 | fileObjects.splice(fileIndex, 1);
269 | this.fileObjects = fileObjects;
270 | if (this.onDelete) {
271 | this.onDelete(file);
272 | }
273 | if (this.onChange) {
274 | this.onChange(this.state.fileObjects.map(fileObject => fileObject.file));
275 | }
276 | this.openSnackBar = true;
277 | this.snackbarMessage = this.getFileRemovedMessage(file.name);
278 | this.snackbarVariant = "info";
279 | };
280 |
281 | handleDropRejected(rejectedFiles, evt) {
282 | this.dropRejected = true;
283 | let message = "";
284 | rejectedFiles.forEach(rejectedFile => {
285 | message += this.getDropRejectMessage(
286 | rejectedFile,
287 | this.acceptedFileTypes,
288 | this.maxFileSize
289 | );
290 | });
291 | if (this.onDropRejected) {
292 | this.onDropRejected(rejectedFiles, evt);
293 | }
294 | this.openSnackBar = true;
295 | this.snackbarMessage = message;
296 | this.snackbarVariant = "error";
297 | }
298 |
299 | handleCloseSnackbar = () => {
300 | this.openSnackBar = false;
301 | };
302 | }
303 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/flowFileUpload/flowFileUpload.js-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 48.0
4 | true
5 |
6 | lightning__HomePage
7 | lightning__FlowScreen
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/flowFileUpload/helpers/helpers.js:
--------------------------------------------------------------------------------
1 | function accepts(file, acceptedFiles) {
2 | if (file && acceptedFiles) {
3 | const acceptedFilesArray = Array.isArray(acceptedFiles)
4 | ? acceptedFiles
5 | : acceptedFiles.split(",");
6 | const fileName = file.name || "";
7 | const mimeType = file.type || "";
8 | const baseMimeType = mimeType.replace(/\/.*$/, "");
9 |
10 | return acceptedFilesArray.some((type) => {
11 | const validType = type.trim();
12 | if (validType.charAt(0) === ".") {
13 | return fileName.toLowerCase().endsWith(validType.toLowerCase());
14 | } else if (validType.endsWith("/*")) {
15 | // This is something like a image/* mime type
16 | return baseMimeType === validType.replace(/\/.*$/, "");
17 | }
18 | return mimeType === validType;
19 | });
20 | }
21 | return true;
22 | }
23 |
24 | function isImage(file) {
25 | if (file.type.split("/")[0] === "image") {
26 | return true;
27 | }
28 | }
29 | function convertBytesToMbsOrKbs(filesize) {
30 | let size = "";
31 | // I know, not technically correct...
32 | if (filesize >= 1000000) {
33 | size = filesize / 1000000 + " megabytes";
34 | } else if (filesize >= 1000) {
35 | size = filesize / 1000 + " kilobytes";
36 | } else {
37 | size = filesize + " bytes";
38 | }
39 | return size;
40 | }
41 |
42 | async function createFileFromUrl(url) {
43 | const response = await fetch(url);
44 | const data = await response.blob();
45 | const metadata = { type: data.type };
46 | const filename = url.replace(/\?.+/, "").split("/").pop();
47 | const ext = data.type.split("/").pop();
48 | return new File([data], `${filename}.${ext}`, metadata);
49 | }
50 | export { createFileFromUrl, accepts };
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "salesforce-app",
3 | "private": true,
4 | "version": "1.0.0",
5 | "description": "Salesforce App",
6 | "scripts": {
7 | "lint": "npm run lint:lwc",
8 | "lint:lwc": "eslint force-app/main/default/lwc",
9 | "test": "npm run test:unit",
10 | "test:unit": "sfdx-lwc-jest",
11 | "test:unit:watch": "sfdx-lwc-jest --watch",
12 | "test:unit:debug": "sfdx-lwc-jest --debug",
13 | "test:unit:coverage": "sfdx-lwc-jest --coverage",
14 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
15 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\""
16 | },
17 | "devDependencies": {
18 | "@prettier/plugin-xml": "^0.7.0",
19 | "@salesforce/eslint-config-lwc": "^0.4.0",
20 | "@salesforce/sfdx-lwc-jest": "^0.7.0",
21 | "eslint": "^5.16.0",
22 | "prettier": "^1.19.1",
23 | "prettier-plugin-apex": "^1.0.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------