Repository: parse-community/Parse-SDK-JS
Pull request #373 (2016-11-05 16:42:45)

Add support for providing file upload progress.
It would be very convenient if developers could get progress when uploading a file with Parse.File.  The use case would look something like this:

```
var parseFile = new Parse.File(file.name, file);
parseFile.save({
    progress: (value) => {
        console.log("TODO show progress to user : "+value);
    }
}).then();
```

See also https://www.parse.com/questions/file-upload-progress-javascript-api

jcguarinpenaranda: Please merge this!!
flovilmart: @aaron-blondeau-dose Thanks for the PR, could you fix the conflicts so we can get that one in for the next release?
flovilmart: @aaron-blondeau-dose ping?
flovilmart: @aaron-blondeau-dose I'm doing some cleanup here, it seems that it diverged a lot since your PR, are you still interested at getting this one in?
dplewis: @flovilmart I can help out with this
codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=h1) Report
> Merging [#373](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/cea20a6bc60230728525c51ccead8b23955527a5?src=pr&el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373/graphs/tree.svg?width=650&token=8nCENM0QHj&height=150&src=pr)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #373      +/-   ##
==========================================
+ Coverage   85.75%   85.76%   +0.01%     
==========================================
  Files          48       48              
  Lines        3909     3913       +4     
  Branches      890      892       +2     
==========================================
+ Hits         3352     3356       +4     
  Misses        557      557
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/ParseFile.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373/diff?src=pr&el=tree#diff-c3JjL1BhcnNlRmlsZS5qcw==) | `94.28% <100%> (ø)` | :arrow_up: |
| [src/RESTController.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373/diff?src=pr&el=tree#diff-c3JjL1JFU1RDb250cm9sbGVyLmpz) | `85.49% <100%> (+0.45%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=footer). Last update [cea20a6...196ba8e](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/373?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

dplewis: @flovilmart Quick update on this.

This will only work in the browser. I haven't tried it on IE8 / IE9.

The xmlhttprequest node package seems to be missing the progress event. I would do a PR to add support for it but it seems like it isn't well maintained.

https://github.com/driverdan/node-XMLHttpRequest

What do you think?

Patch:
diff --git a/src/ParseFile.js b/src/ParseFile.js
index 4d475fa..a9eab3c 100644
--- a/src/ParseFile.js
+++ b/src/ParseFile.js
@@ -10,6 +10,7 @@
  */
 /* global File */
 import CoreManager from './CoreManager';
+import type { FullOptions } from './RESTController';
 
 type Base64 = { base64: string };
 type FileData = Array<number> | Base64 | File;
@@ -157,9 +158,14 @@ class ParseFile {
   /**
    * Saves the file to the Parse cloud.
    * @param {Object} options
+   *  * Valid options are:<ul>
+   *   <li>useMasterKey: In Cloud Code and Node only, causes the Master Key to
+   *     be used for this request.
+   *   <li>progress: In Browser only, callback for upload progress
+   * </ul>
    * @return {Promise} Promise that is resolved when the save finishes.
    */
-  save(options?: { useMasterKey?: boolean, success?: any, error?: any }) {
+  save(options?: FullOptions) {
     options = options || {};
     var controller = CoreManager.getFileController();
     if (!this._previousSave) {
@@ -236,7 +242,7 @@ class ParseFile {
 }
 
 var DefaultController = {
-  saveFile: function(name: string, source: FileSource) {
+  saveFile: function(name: string, source: FileSource, options?: FullOptions) {
     if (source.format !== 'file') {
       throw new Error('saveFile can only be used with File-type sources.');
     }
@@ -254,10 +260,10 @@ var DefaultController = {
       url += '/';
     }
     url += 'files/' + name;
-    return CoreManager.getRESTController().ajax('POST', url, source.file, headers).then(res=>res.response)
+    return CoreManager.getRESTController().ajax('POST', url, source.file, headers, options).then(res=>res.response)
   },
 
-  saveBase64: function(name: string, source: FileSource, options?: { useMasterKey?: boolean, success?: any, error?: any }) {
+  saveBase64: function(name: string, source: FileSource, options?: FullOptions) {
     if (source.format !== 'base64') {
       throw new Error('saveBase64 can only be used with Base64-type sources.');
     }
diff --git a/src/RESTController.js b/src/RESTController.js
index 3fea057..a95530f 100644
--- a/src/RESTController.js
+++ b/src/RESTController.js
@@ -18,6 +18,7 @@ export type RequestOptions = {
   installationId?: string;
   batchSize?: number;
   include?: any;
+  progress?: any;
 };
 
 export type FullOptions = {
@@ -26,6 +27,7 @@ export type FullOptions = {
   useMasterKey?: boolean;
   sessionToken?: string;
   installationId?: string;
+  progress?: any;
 };
 
 var XHR = null;
@@ -42,7 +44,7 @@ if (typeof XDomainRequest !== 'undefined' &&
   useXDomainRequest = true;
 }
 
-function ajaxIE9(method: string, url: string, data: any) {
+function ajaxIE9(method: string, url: string, data: any, options?: FullOptions) {
   return new Promise((resolve, reject) => {
     var xdr = new XDomainRequest();
     xdr.onload = function() {
@@ -66,16 +68,20 @@ function ajaxIE9(method: string, url: string, data: any) {
       };
       reject(fakeResponse);
     };
-    xdr.onprogress = function() { };
+    xdr.onprogress = function() {
+      if(options && typeof options.progress === 'function') {
+        options.progress(xdr.responseText);
+      }
+    };
     xdr.open(method, url);
     xdr.send(data);
   });
 }
 
 const RESTController = {
-  ajax(method: string, url: string, data: any, headers?: any) {
+  ajax(method: string, url: string, data: any, headers?: any, options?: FullOptions) {
     if (useXDomainRequest) {
-      return ajaxIE9(method, url, data, headers);
+      return ajaxIE9(method, url, data, headers, options);
     }
 
     var res, rej;
@@ -141,7 +147,28 @@ const RESTController = {
           ' (NodeJS ' + process.versions.node + ')';
       }
 
+      if(options && typeof options.progress === 'function') {
+        if (xhr.upload) {
+          xhr.upload.addEventListener('progress', (oEvent) => {
+            if (oEvent.lengthComputable) {
+              options.progress(oEvent.loaded / oEvent.total);
+            } else {
+              options.progress(null);
+            }
+          });
+        } else if (xhr.addEventListener) {
+          xhr.addEventListener('progress', (oEvent) => {
+            if (oEvent.lengthComputable) {
+              options.progress(oEvent.loaded / oEvent.total);
+            } else {
+              options.progress(null);
+            }
+          });
+        }
+      }
+
       xhr.open(method, url, true);
+
       for (var h in headers) {
         xhr.setRequestHeader(h, headers[h]);
       }
@@ -225,8 +252,7 @@ const RESTController = {
       }
 
       var payloadString = JSON.stringify(payload);
-
-      return RESTController.ajax(method, url, payloadString).then(({ response }) => {
+      return RESTController.ajax(method, url, payloadString, {}, options).then(({ response }) => {
         return response;
       });
     }).catch(function(response: { responseText: string }) {


Test patch:
diff --git a/src/__tests__/ParseFile-test.js b/src/__tests__/ParseFile-test.js
index 009c046..1879533 100644
--- a/src/__tests__/ParseFile-test.js
+++ b/src/__tests__/ParseFile-test.js
@@ -6,17 +6,20 @@
  * LICENSE file in the root directory of this source tree. An additional grant
  * of patent rights can be found in the PATENTS file in the same directory.
  */
-
+/* global File */
 jest.autoMockOff();
 
 const ParseFile = require('../ParseFile').default;
 const CoreManager = require('../CoreManager');
 
 function generateSaveMock(prefix) {
-  return function(name) {
+  return function(name, payload, options) {
+    if (options && typeof options.progress === 'function') {
+      options.progress(0.5);
+    }
     return Promise.resolve({
       name: name,
-      url: prefix + name
+      url: prefix + name,
     });
   };
 }
@@ -183,6 +186,22 @@ describe('ParseFile', () => {
     expect(a.equals(b)).toBe(false);
     expect(b.equals(a)).toBe(false);
   });
+
+  it('reports progress during save when source is a File', () => {
+    const file = new ParseFile('progress.txt', new File(["Parse"], "progress.txt"));
+
+    const options = {
+      progress: function(){}
+    };
+    jest.spyOn(options, 'progress');
+
+    return file.save(options).then(function(f) {
+      expect(options.progress).toHaveBeenCalledWith(0.5);
+      expect(f).toBe(file);
+      expect(f.name()).toBe('progress.txt');
+      expect(f.url()).toBe('http://files.parsetfss.com/a/progress.txt');
+    });
+  });
 });
 
 describe('FileController', () => {
diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js
index 2eb18e3..c35bc58 100644
--- a/src/__tests__/RESTController-test.js
+++ b/src/__tests__/RESTController-test.js
@@ -376,4 +376,31 @@ describe('RESTController', () => {
       'Cannot use the Master Key, it has not been provided.'
     );
   });
+
+  it('reports upload progress of the AJAX request when callback is provided', (done) => {
+    const xhr = mockXHR([{ status: 200, response: { success: true }}], {
+      addEventListener: (name, callback) => {
+        if(name === "progress") {
+          callback({
+            lengthComputable: true,
+            loaded: 5,
+            total: 10
+          });
+        }
+      }
+    });
+    RESTController._setXHR(xhr);
+
+    const options = {
+      progress: function(){}
+    };
+    jest.spyOn(options, 'progress');
+
+    RESTController.ajax('POST', 'files/upload.txt', {}, {}, options).then(({ response, status }) => {
+      expect(options.progress).toHaveBeenCalledWith(0.5);
+      expect(response).toEqual({ success: true });
+      expect(status).toBe(200);
+      done();
+    });
+  });
 });
diff --git a/src/__tests__/test_helpers/mockXHR.js b/src/__tests__/test_helpers/mockXHR.js
index 87b50f1..a144642 100644
--- a/src/__tests__/test_helpers/mockXHR.js
+++ b/src/__tests__/test_helpers/mockXHR.js
@@ -14,8 +14,9 @@
  *   { status: ..., response: ... }
  * where status is a HTTP status number and result is a JSON object to pass
  * alongside it.
+ * `upload` can be provided to mock the XMLHttpRequest.upload property.
  */
-function mockXHR(results) {
+function mockXHR(results, upload) {
   const XHR = function() { };
   let attempts = 0;
   XHR.prototype = {
@@ -27,7 +28,8 @@ function mockXHR(results) {
       this.readyState = 4;
       attempts++;
       this.onreadystatechange();
-    }
+    },
+    upload: upload
   };
   return XHR;
 }