May 4, 2018 - Davide Papagni

Powering your Ember apps with Rails' ActiveStorage

In this article we will cover the integration between EmberJS and Active Storage, the new major feature released with Rails 5.2. We'll be introducing ember-active-storage, an addon we developed at Algonauti.

Introducing Active Storage

Active Storage facilitates uploading files to a cloud storage service (like Amazon S3 or Google Cloud Storage) and attaching those files to Active Record objects. One of its coolest features is direct uploads, which allows frontend to securely upload files directly to the cloud. This way, the backend app saves bandwidth, and the end users save time.

In the next sections, we'll be creating a demo project showing ember-active-storage addon in action with a Rails 5.2 backend. It could take up to 20 minutes; if you're too busy, feel free to jump to the repository with the complete example code.

Create a new ember-cli-rails project

For a better understanding of what an ember-cli-rails project is and its details, have a look at our dedicated article. Following is an updated list of commands using yarn as the frontend package manager.

$ rails new ember-active-storage-example --skip-bundle
$ bundle add ember-cli-rails
$ rails g ember:init
$ ember new frontend --skip-git --yarn
$ cd frontend
$ yarn add ember-cli-rails-addon --dev

In config/routes.rb:

  mount_ember_app :frontend, to: '/'

Active Storage Configuration

Active Storage uses two tables in your application’s database named active_storage_blobs and active_storage_attachments. Let's generate a migration that creates those tables, and update our database:

$ rails active_storage:install
$ bundle exec rake db:migrate

Generate a model with attachment

To our demo's purpose, let's create a Company model with a logo file attached.

$ rails g model company name:string
$ bundle exec rake db:migrate

Now, go to company.rb and insert:

  has_one_attached :logo

Let's move to the frontend folder and generate the ember model for Company.

$ ember g model company name:string logo:string

Direct upload with ember-active-storage addon

Let's now move to the frontend folder and install the ember-active-storage addon:

$ ember install ember-active-storage --yarn

We want to build a file-upload component which will interact with the activeStorage service provided by the above addon, in order to let our users upload files.

$ ember g component file-upload

We want the component to not be bound to a specific ember model, so we want to pass it an onFileUploaded action which would implement model-specific logic with the uploaded file.

Let's start with the component's template: it consists of a file input and a very basic upload progress feedback:

<input
  multiple='false'
  onchange={{action 'upload'}}
  accept='image/png,image/jpeg'
  type='file'
/>
<p>Progress: {{uploadProgress}}%</p>

The upload action will:

Let's see how it looks like:

import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
import { isPresent } from '@ember/utils';

export default Component.extend({
  activeStorage: service(),

  uploadProgress: 0,

  actions: {
    upload(event) {
      const files = event.target.files;
      if (isPresent(files)) {
        const directUploadURL = '/rails/active_storage/direct_uploads';
        for (var i = 0; i < files.length; i++) {
          get(this, 'activeStorage')
            .upload(files.item(i), directUploadURL, {
              onProgress: (progress) => {
                set(this, 'uploadProgress', progress);
              },
            })
            .then((blob) => {
              get(this, 'onFileUploaded')(blob);
            });
        }
      }
    },
  },
});

A few more notes:

Saving a model with attachment

Now that we have a file-upload component, we want to use it for creating a company record with a logo. We'll need some preparatory work:

Backend APIs

We'll be using active_model_serializers to generate JSON responses compliant to the JSON API standard - which is also the format Ember Data expects by default.

$ bundle add active_model_serializers

You'll also need an initializer for AMS, and make sure that :json_api is set as the default adapter. See the AMS initializer on our example repo.

We can now generate a serializer for our Company model:

$ be rails g serializer company

Let's return the logo URL to the frontend, by using Active Storage features:

class CompanySerializer < ActiveModel::Serializer
 include Rails.application.routes.url_helpers

 attributes :id, :name, :logo

 def logo
   url_for(object.logo) if object.logo.attached?
 end
end

Now, let's generate Company APIs:

$ rails g controller Companies index create

Implementation details are outside of the scope of this article, you can see the full controller on our example repo.

Company pages

First, let's generate a companies route:

$ ember g route companies

Its model() hook will return all companies by invoking findAll(). See it on the example repo. The template will just loop over those companies and show name and logo for each. See it on the example repo.

Second, let's generate a companies.new route:

$ ember g route 'companies/new'

Its model() hook will return a new company record. See it on the example repo. The template will show a form with a text input for name and a file input for logo; of course, we'll be using our file-upload component. This is the focus of the current article, let's dive into it in the next section!

Here's how our create company template looks like:

<div class='container'>
  <form>
    <div class='form-group row'>
      <label>Company Name</label>
      {{input type='text' class='form-control' value=(mut (get model 'name'))}}
    </div>
    <div class='form-group row'>
      {{file-upload
        onFileUploaded=(action 'setCompanyLogo')
        class='form-control-file'
      }}
    </div>
    <div class='form-group'>
      <div class='col-sm-offset-2 col-sm-10'>
        <button {{action 'save'}} class='btn btn-sm btn-primary'>Save</button>
      </div>
    </div>
  </form>
</div>

We need a controller to implement the above actions:

$ ember g controller 'companies/new'

Here's how it would look like:

import Controller from '@ember/controller';
import { get, set } from '@ember/object';

export default Controller.extend({
  actions: {
    setCompanyLogo(blob) {
      set(get(this, 'model'), 'logo', get(blob, 'signedId'));
    },
    save() {
      get(this, 'model')
        .save()
        .then(() => this.transitionToRoute('companies'));
    },
  },
});

What's more?

Powering your Ember apps with Rails' ActiveStorage via @algodave

Click to tweet