Dropbox drag and drop file upload with Ember.js

Ember.js version used: 2.0.0

ember-cli version used: 1.13.8

Backend not covered

New target, same goal

After a while since I wrote my Ember.js drag and drop file upload - the right(ish) way I now bring to you a new twist in this tale.

This time I wanted to wrap the Dropbox API into an Ember.js service, for ease use and also to try out the different problems that one faces while integrating a third party api into an Ember.js application.

To arms...with similar guns

That being said, we are going to use the same base file-uplaoder.js to get us the file uploader component. The only noticeable difference, other than the ES6 sexyness applied to it, is that we also trigger the upload event with the click event for better UX.

Let's roll, but first...

Like any third party api we need to have an application to request user's data and permission. To do so, go to Dropbox Developer website and create a new 'Dropbox API app' app on the App Console. Once you have done that you will be provided with a app details page with your App key.

That key will be used to identify and allow your application to request user's details and permissions to upload and download files.

Also, on that details page inform dropbox of the allowed Redirect URIs as shown:

alt

With that in hand let's create our application with the standard ember-cli command

ember new ember-dropbox-example && cd ember-dropbox-example

Login in

In order to login with dropbox into your application all you need to do is to have a link pointing to the Authorise endpoint with your App key and authorised Redirect URI as explained above.

<a href="https://www.dropbox.com/1/oauth2/authorize?response_type=token&client_id=<appid>&redirect_uri=http://localhost:4200/sessions">  
  login with dropbox
</a>  

We chose to use the token flow, response_type=token, as it is more useful to future requests.

As a successful response to that authorisation flow we get a redirection to our informed redirect_uri with a hash parameter containing the access_token and the user's uid, among others. In order to make use of that hash parameter we have to get our route extracting it and preparing a model with it to our controller. With that in mind let's create our route with the following command:

ember g route sessions

Also, let's create our first model, credential, to receive the data from the Dropbox API that will represent our user's access info.

ember g model credential accessToken:string authMethod:string userId:string user:belongsTo

And the user model that is referenced on the credential model.

ember g model user name:string email:string credentials:hasMany

On our route file let's use our model hook to grab the hash parameter and extract the data out of it, to then create our model to be passed along to the controller.

import Ember from 'ember';

export default Ember.Route.extend({  
 model() {
    var params,
        credential;

    params = this.get('utils').getAuthorisationData();

    return this.store.query('credential', {
                                            userId: params.userId
                                          }).then((cred)=>{
      if(Ember.isPresent(cred)) {
        return cred.get('firstObject');
      } else {
        credential = this.store.createRecord('credential', params);
        credential.save();

        return credential;
      }
    });
  }
});

That model hook will extract our data needed to create a new credential, or retrieve an existing one, and pass it to our controller. But if we have a new credential being created, also means that we need to create the user for it. So, let's get our user created.

  model() {
    // ...
  },

  afterModel(model) {
    model.get('user').then((user)=>{
      if(Ember.isPresent(user)) {
        model.set('user', user);
      } else {
        this.get('utils').getUserInfo(model.get('accessToken'))
                         .then(({display_name:name, email})=>{

          this.store.createRecord('user', {name, email}).save().then((user)=>{
            model.set('user', user);
            model.save();
          });
        });
      }
    });
  }

You can notice that we are using a special service called dropbox-utils. Let's create that service, and let's see what those getAuthorisationData and getUserInfo functions looks like.

First, to create the service run ember g service dropbox-utils. And let's put the following code in it.

import Ember from 'ember';

export default Ember.Service.extend({  
  getAuthorisationData(){
    var params = {};

    window.location.hash.replace('#', '').split('&').forEach((item)=>{
      var parsed = item.split('=');
      params[parsed[0].camelize()] = parsed[1];
    });

    params.authMethod = "dropbox";
    params.userId = params.uid;

    delete params.uid;

    return  params;
  },

  getUserInfo(accessToken){
    return Ember.$.ajax({
      url: `https://api.dropbox.com/1/account/info?access_token=${accessToken}`,
      dataType: 'json'
    });
  }
});

The first method is to simply extract the hash data returned from the dropbox authorise call, translating it into a meaningful data to our application. The second is simply a wrapper to a jQuery.ajax call, which returns a promise for us to use.

The reason why we prefer to isolate such calls into one place/service is that in the future, when dropbox updates it's api, or you decide to use another provider, it's a one stop change instead of searching similar calls in your project.

Getting the file to be uploaded

On our sessions template we will provide the user with the file-uploader component, and tie the events to our controller so we receive the file to be uploaded to Dropbox correctly.

<h1>Hello, {{model.user.name}}<br></h1>

{{file-uploader isDisabled=uploadDisabled fileInputChanged="receiveFile" uploadProgress="uploadProgress" }}

{{#if isDownloading}}
  <b>loading...</b>
{{/if}}

{{#each assets as |file|}}
  {{file.path}}<br>
{{/each}}

Moving to our controller, we will create the actions needed by the file-uploader and the upload action to be executed.

import Ember from 'ember';

export default Ember.Controller.extend({  
  assets: [],
  uploader: Ember.inject.service('dropbox-uploader'),

  initializeUploader: function(){
    this.set('uploader.accessToken', this.get('model.accessToken'));
  }.observes('model.accessToken'),

  actions:{
    receiveFile(file){
      this.set('uploadDisabled', true);

      this.get('uploader').upload(file).then((file)=>{
        var asset = this.store.createRecord('asset', file);
        asset.save();

        this.get('assets').pushObject(asset);
        this.set('isDownloading', false);
      });

    },

    uploadProgress(progress){
      if(progress === 1) {
        this.set('isDownloading', true);
      }
    }
  }
});

Addendum

It is worth mentioning that for the initializeUploader observer we are not using a Method Definition neither we are using an Arrow Function.

The reason for that is that method definitions behave like a function declaration, thus it will throw a SyntaxError if you try to immediately access properties.

And by trying to use arrow functions you would have a scope problem, due to the fact that ES6 modules executes on strict mode by default. And the default behaviour of the arrow function is to scope the this object to the scope of the executing object. In this case the function belongs to the module itself, and the 'global' this is would be undefined by default, as result of the strict mode execution of this scope. That is also the same reason why we are not doing the typical 'manual scoping' of this under _this or self.

Now, continuing with our normal schedule...

In our controller code you can notice that we make use of another service, the dropbox-uploader. Let's create it and it's upload call. Also, we will introduce another model to our application, the asset model.

To create the asset model run the following command.

ember g model asset name:string path:string size:string type:string

To create it simply run ember g service dropbox-uploader. And in it we should have the following code.

import Ember from 'ember';

export default Ember.Service.extend({  
  accessToken: '',

  upload(file){
    var promise;

    promise = new Ember.RSVP.Promise((resolve, reject)=> {
      var reader = new FileReader();
      reader.readAsArrayBuffer(file);

      reader.onload = ({target:{result}})=> {
        Ember.$.ajax({
          headers: {
            Authorization: `Bearer ${this.get('accessToken')}`
          },

          url: `https://content.dropboxapi.com/1/files_put/auto/${file.name}`,
          type: 'PUT',
          data: result,
          contentType: file.type,
          dataType: 'json',
          processData: false,
          crossDomain: true,
          crossOrigin: true,

          success: ({path, size})=> {
            resolve({
              name: file.name,
              path,
              size,
              type: file.type
            });
          },

          error: (reason)=>{
            reject(reason);
          },

          xhr: ()=>{
            var xhr = new window.XMLHttpRequest();
            //Upload progress
            xhr.upload.addEventListener("progress", ({lengthComputable, loaded, total})=>{
              if (lengthComputable){
                var percentComplete = loaded / total;

                Ember.$('[data-uploader]').trigger({
                  type:"uploadProgress",
                  progress:percentComplete
                });
              }
            }, false);
            return xhr;
          }
        });

      };
    });

    return promise;
  }
});

In order to correctly upload to dropbox we need to send the file's binary data to the files_put endpoint. To correctly read from the file we will use the FileReader API. It is worth mentioning that to the files_put endpoint we need to send our accessToken as a header instead of a query parameter.

On success, we will resolve our promise passing an object with the needed data to create a new asset instance.

Same as on the Ember.js drag and drop file upload - the right(ish) way article we will watch for the download event in order to communicate the progress to the file-uploader component.

Making things a bit more interesting

To bring more substantial meaning to this demo, let's add the ability to download files that we upload. In order to do that let's change a bit our sessions.hbs file.

{{#each assets as |file|}}
  <b {{action 'downloadFile' file}} class="asset">{{file.path}}</b><br>
{{/each}}

Now our list will dispatch an action called downloadFile on click to. Let's create that action and process our download.

import Ember from 'ember';

export default Ember.Controller.extend({  
  // ...
  actions:{
    receiveFile(file){
      // ...
    },

    uploadProgress(progress){
      // ...
    },

    downloadFile(file){
      this.set('isDownloading', true);
      this.get('uploader').download(file).then((objectUrl)=>{
        window.open(objectUrl);
        this.set('isDownloading', false);
      });
    }
  }
});

The idea behind this action is to call the files endpoint. The successful result of this call will return us the binary content of the requested file. so our dropbox-uploader instance's download function should get that binary file and convert it into a binary string in order to force the browser to open a new window wit the file for the user to save.

So on our service we should have the following.

import Ember from 'ember';

export default Ember.Service.extend({  
  accessToken: '',

  upload(file){
    // ...
  },

  download(file){
    var promise;

    promise = new Ember.RSVP.Promise((resolve, reject)=> {
      var xhr = new XMLHttpRequest();

      xhr.open("GET", `https://content.dropboxapi.com/1/files/auto${file.get('path')}?access_token=${this.get('accessToken')}`, true);
      xhr.responseType = "arraybuffer";
      xhr.onload = ()=> {
        var blob = new Blob([xhr.response], {type: file.get('type')});
        var objectUrl = URL.createObjectURL(blob);
        resolve(objectUrl);
      };

      xhr.onerror = (reason)=> {
        reject(reason);
      };

      xhr.send();
    });

    return promise;
  }
});

For this step we are going to use the native XMLHttpRequest API in order to receive the response as arraybuffer, since jQuery's ajax response type, dataType, only allows us to point or intelligently guess between xml, json, script, or html.

With the response we convert the binary data, Blob, into a object url in order to open a new window with the downloadable.

The full code to this article can be found at https://github.com/WebCloud/ember-dropbox-example/

That's all for now folks :)