Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

September 17, 2019

Last updated November 5, 2023

Ruby on Rails Drag and Drop Uploads with Active Storage, Stimulus.js and Dropzone.js

This tutorial is about using Active Storage from Ruby on Rails 6.0 to create a drag and drop upload experience from scratch using Stimulus.js and Dropzone.js.

Download Source Code

Objective

The goal of this experience is to introduce drag and drop functionality and tie it to Rails Active Storage behind the scenes. I want to be able to drag and drop multiple files for upload as well as pass options that regulate what conditions can be passed around those files. Those conditions could be anything from image type to file size and more.

Dependencies:

How we solve the problem

With Webpack support now in Rails, I plan to leverage a couple of JavaScript libraries to help handle a lot of the drag and drop functionality. On top of this, I use some of the built-in JavaScript from @rails/activestorage to create a direct upload pattern. Ultimately, we will still use serverside rendered UI which is normal for a traditional Ruby on Rails application but enhance it with JavaScript to feel more dynamic.

Creating the app

This guide assumes you'll leverage my kickoff_tailwind starter template. It's completely optional to use but does save some configuration time. I mostly use this to save time for screencasts.

$ rails new active_storage_drag_and_drop -m kickoff_tailwind/template.rb --webpack=stimulus

Running the script above will create a new rails app and pass my template through. To add to that effect we leverage Stimulus JS in this tutorial. That gets installed on the fly by passing --webpack=stimulus

Stimulus.js has aspects of Vue, React, etc.. but is designed to enhance the front-end of a Rails application rather than rewrite it with a new framework. It works much like the Rails controller concept where you define a controller with corresponding methods/actions that actually do something.

I may do an overview/guide of Stimulus.js coming up but consider this a healthy taste of what is possible.

Running this command should get us close to being ready to start the app.

Dropzone.js

Another really nice dependency I decided to utilize is the Dropzone.js library. There are a variety of ways to harness the API around this library and we'll hook into some. The general idea is to get files of our choice to upload via Active Storage using drag and drop functionality. Dropzone.js helps solve many problems around that type of experience.

Install dropzone by running the following in your terminal from within your Rails application root folder.

yarn add dropzone

That does it for node_module dependencies. Let's get some Rails logic in order next.

Install Active Storage

Active Storage support is made possible by running one command in a new Rails app. Run the following command and migrate the database to set this up. Be sure not to skip this step. It's crucial for anything here to work going forward.

$ rails active_storage:install

This copies a migration file to the app which adds the necessary tables to utilize uploads.

$ rails db:migrate

== 20190915152618 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs, {})
   -> 0.0028s
-- create_table(:active_storage_attachments, {})
   -> 0.0025s
== 20190915152618 CreateActiveStorageTables: migrated (0.0054s) ===============

Creating a resource for the uploads

We need a form to tie our Active Storage uploads to. I'll use a Post model as an example. We'll assume a Post will have title, body and user_id columns in the database.

Below I scaffold a Post to save some time.

$ rails generate scaffold Post title:string body:text user:references

      invoke  active_record
      create    db/migrate/20190915153310_create_posts.rb
      create    app/models/post.rb
      invoke    test_unit
      create      test/models/post_test.rb
      create      test/fixtures/posts.yml
      invoke  resource_route
       route    resources :posts
      invoke  scaffold_controller
      create    app/controllers/posts_controller.rb
      invoke    erb
      create      app/views/posts
      create      app/views/posts/index.html.erb
      create      app/views/posts/edit.html.erb
      create      app/views/posts/show.html.erb
      create      app/views/posts/new.html.erb
      create      app/views/posts/_form.html.erb
      invoke    test_unit
      create      test/controllers/posts_controller_test.rb
      create      test/system/posts_test.rb
      invoke    helper
      create      app/helpers/posts_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/posts/index.json.jbuilder
      create      app/views/posts/show.json.jbuilder
      create      app/views/posts/_post.json.jbuilder
      invoke  assets
      invoke    scss
      create      app/assets/stylesheets/posts.scss
      invoke  scss
      create    app/assets/stylesheets/scaffolds.scss

The command above will create a new Post resource with full RESTful routing. A scaffold in Rails is a quick was to create the complete CRUD concept with next to no effort.

Notice I didn't include anything to do with an image or file upload here. This is intended.

I also passed user:references which tells Rails to create a user_id column on the posts table (once migrated) as well as an index for that column.

Note: If you are not using my kickoff_tailwind template you probably want to skip the user:references addition to this command. The reason for this is that there is already a User model in place when creating a new app when referencing the template.

Scaffolds generate a scaffolds.scss file and posts.scss file. We can delete both since we are using Tailwind in this guide.

One more migration and we should be set

$ rails db:migrate

Heading to localhost:3000/posts should give you an index of all the posts.

Locking down the controller

Even though we are currently signed out I can still create a new post localhost:3000/posts/new which probably isn't a good thing. My kickoff_tailwind template includes devise. As a result we can make a user sign in first before hitting the posts/new path.

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show, :index] # add this line
  ...

end

Adding this before action allows us to require a new session from any user going forward on all paths except for the index and show actions.

Go ahead and create a test account to bypass this then head to localhost:3000/posts/new.

Enhancing the Post form

Our Post form is a partial inside the app/views/posts directory called _form.html.erb. I changed the markup to include some basic styling. It's nothing fancy but beats no styles.

<!-- app/views/posts/_form.html.erb-->

<%= form_with(model: post, local: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :body, class: "label" %>
    <%= form.text_area :body, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.submit  class: "btn-default btn" %>
  </div>
<% end %>

I removed the reference to user_id here since we'll assign it behind the scenes in the controller (another reason we require the user to be authenticated before hitting /posts/new). Here is the current state of that file after the update. I deleted all the comments for clarity sake.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show, :index]
  before_action :set_post, only: [:show, :edit, :update, :destroy]


  def index
    @posts = Post.all
  end

  def show
  end

  def new
    @post = Post.new
  end

  def edit
  end

  def create
    @post = Post.new(post_params)
    @post.user_id = current_user.id

    respond_to do |format|
      if @post.save
        format.html { redirect_to @post, notice: 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @post }
      else
        format.html { render :new }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @post.update(post_params)
        format.html { redirect_to @post, notice: 'Post was successfully updated.' }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @post.destroy
    respond_to do |format|
      format.html { redirect_to posts_url, notice: 'Post was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private

    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.require(:post).permit(:title, :body, :user_id)
    end
end

Adding attachments

If you recall, I scaffolded the Post resource with no mention of an image or file upload. This was on purpose. With Active Storage you no longer need these columns directly on the database table. It will be housed within its own table for reference later. This all happens inside the model.

Let's add a reference to attachments in the Post model.

# app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
  has_one_attached :feature_image # add this line
end

Here I used a method relative to Active Storage called has_one_attached. There is also has_many_attached (for multiple uploads). You can name this whatever you please. I chose feature_image for the Post since it's common that a blog post might have one.

With this addition, all the hard work is done. We can extend our posts_controller and _form.html.erb partial to now reference the attachment.

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
...

 private

    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.require(:post).permit(:title, :body, :user_id, :feature_image)
    end
end

In the controller, we need to white-list the new field within the post_params private method.

<!-- app/views/posts/_form.html.erb-->
<%= form_with(model: post, local: true, multipart: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :body, class: "label" %>
    <%= form.text_area :body, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :feature_image, class: "label" %>
    <%= form.file_field :feature_image, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.submit  class: "btn-default btn" %>
  </div>
<% end %>

We extend the form to include the new :feature_image field. Not that it's a file_field. Since we are using files in the form now the form helper needs to be extended to be multipart:true.

That should get you some type of UI like this at the moment

https://i.imgur.com/U8N9bP3.png

That feature image area looks pretty boring so let's move on to getting drag and drop working.

I'll modify our file_field markup to the following on the form

<div class="mb-6">
    <%= form.label :feature_image, class: "label" %>
    <div class="dropzone dropzone-default dz-clickable" data-controller="dropzone" data-dropzone-max-file-size="2" data-dropzone-max-files="1">
    <%= form.file_field :feature_image, direct_upload: true, data: { target: 'dropzone.input' } %>
    <div class="dropzone-msg dz-message needsclick text-gray-600">
      <h3 class="dropzone-msg-title">Drag here to upload or click here to browse</h3>
      <span class="dropzone-msg-desc text-sm">2 MB file size maximum. Allowed file types png, jpg.</span>
      </div>
    </div>
  </div>

Surrounding the file_field is a div containing some data attributes for Stimulus.js to hook into. More data attributes are bound to the div which allow me to pass options through for the file amount and size via the front-end.

This is useful for reusing the same component later in other portions of a given application. Notice also all the dropzone classes. These are necessary for the dropzone dependency which we will integrate shortly. At the moment, there aren't that many visual changes on the field. We still need the necessary JavaScript and a dash of CSS to help with that.

Integrating the JavaScript

Inside our app/javascript directory is a folder called controllers which was created when we created the new app thanks to passing the --webpack=stimulus flag. This convention is common to the Stimulus.js library in that a controller is a component for which handles logic around a place in your views you declare. Naming conventions are crucial to Stimulus so there are a lot of those to get used to. Once you do, it's not all that confusing.

All stimulus controllers require an element with the controller name passed. In our case, this would be like the one you might have seen surrounding the form file field.

<div class="dropzone dropzone-default dz-clickable" data-controller="dropzone" data-dropzone-max-file-size="2" data-dropzone-max-files="1">
  <%= form.file_field :feature_image %>
</div>

the data-controller="dropzone" is the one I'm referring to. This tells stimulus there is indeed a controller mounted and ready to receive instructions. We can write those instructions inside the app/javascript/controllers directory. Given that I named the controller dropzone we need to create a file called dropzone_controller.js in that folder.

dropzone_controller file within file tree

Inside this file will be where all the magic happens. It's worth noting that this file and all the others inside app/javascript/controllers get imported to the main application.js file inside app/javascript/packs/application.js.

// app/javascript/packs/application.js

require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")


import "controllers"
import "stylesheets/application"

That happens inside app/javascript/controllers/index.js where each of the controllers gets imported thanks to those naming conventions. This file is also where Simulus.js gets imported.

// app/javascript/controllers/index.js
// Load all the controllers within this directory and all subdirectories. 
// Controller files must be named *_controller.js.

import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"

const application = Application.start()
const context = require.context("controllers", true, /_controller\.js$/)
application.load(definitionsFromContext(context))

We already load app/javascript/packs/application.js in our main layout view file app/views/layouts/application.html.erb so there's little configuration to do.

<!DOCTYPE html>
<html>
  <head>

   <!-- more code here-->

    <%= javascript_pack_tag  'application', 'data-turbolinks-track': 'reload' %>

  </head>

  <!-- more code here-->

The meat and potatoes

To get started I created a handful of helper functions and imported those from another file. That will live inside the app/javascript/ directory. Create a new folder called helpers. Inside it, add a file called index.js with the following code:

// app/javascript/helpers/index.js

export function getMetaValue(name) {
  const element = findElement(document.head, `meta[name="${name}"]`);
  if (element) {
    return element.getAttribute("content");
  }
}

export function findElement(root, selector) {
  if (typeof root == "string") {
    selector = root;
    root = document;
  }
  return root.querySelector(selector);
}

export function toArray(value) {
  if (Array.isArray(value)) {
    return value;
  } else if (Array.from) {
    return Array.from(value);
  } else {
    return [].slice.call(value);
  }
}

export function removeElement(el) {
  if (el && el.parentNode) {
    el.parentNode.removeChild(el);
  }
}

export function insertAfter(el, referenceNode) {
  return referenceNode.parentNode.insertBefore(el, referenceNode.nextSibling);
}

Here I'm exporting each function so we can import those as needed elsewhere. This extracts some unnecessary logic from dropzone_controller.js and also makes it accessible to other future javascript work should we require it.

Finally, in the dropzone controller file, I added the following code.

import Dropzone from "dropzone";
import { Controller } from "stimulus";
import { DirectUpload } from "@rails/activestorage";
import {
  getMetaValue,
  toArray,
  findElement,
  removeElement,
  insertAfter
} from "helpers";

export default class extends Controller {
  static targets = ["input"];

  connect() {
    this.dropZone = createDropZone(this);
    this.hideFileInput();
    this.bindEvents();
    Dropzone.autoDiscover = false; // necessary quirk for Dropzone error in console
  }

  // Private
  hideFileInput() {
    this.inputTarget.disabled = true;
    this.inputTarget.style.display = "none";
  }

  bindEvents() {
    this.dropZone.on("addedfile", file => {
      setTimeout(() => {
        file.accepted && createDirectUploadController(this, file).start();
      }, 500);
    });

    this.dropZone.on("removedfile", file => {
      file.controller && removeElement(file.controller.hiddenInput);
    });

    this.dropZone.on("canceled", file => {
      file.controller && file.controller.xhr.abort();
    });
  }

  get headers() {
    return { "X-CSRF-Token": getMetaValue("csrf-token") };
  }

  get url() {
    return this.inputTarget.getAttribute("data-direct-upload-url");
  }

  get maxFiles() {
    return this.data.get("maxFiles") || 1;
  }

  get maxFileSize() {
    return this.data.get("maxFileSize") || 256;
  }

  get acceptedFiles() {
    return this.data.get("acceptedFiles");
  }

  get addRemoveLinks() {
    return this.data.get("addRemoveLinks") || true;
  }
}

class DirectUploadController {
  constructor(source, file) {
    this.directUpload = createDirectUpload(file, source.url, this);
    this.source = source;
    this.file = file;
  }

  start() {
    this.file.controller = this;
    this.hiddenInput = this.createHiddenInput();
    this.directUpload.create((error, attributes) => {
      if (error) {
        removeElement(this.hiddenInput);
        this.emitDropzoneError(error);
      } else {
        this.hiddenInput.value = attributes.signed_id;
        this.emitDropzoneSuccess();
      }
    });
  }

  createHiddenInput() {
    const input = document.createElement("input");
    input.type = "hidden";
    input.name = this.source.inputTarget.name;
    insertAfter(input, this.source.inputTarget);
    return input;
  }

  directUploadWillStoreFileWithXHR(xhr) {
    this.bindProgressEvent(xhr);
    this.emitDropzoneUploading();
  }

  bindProgressEvent(xhr) {
    this.xhr = xhr;
    this.xhr.upload.addEventListener("progress", event =>
      this.uploadRequestDidProgress(event)
    );
  }

  uploadRequestDidProgress(event) {
    const element = this.source.element;
    const progress = (event.loaded / event.total) * 100;
    findElement(
      this.file.previewTemplate,
      ".dz-upload"
    ).style.width = `${progress}%`;
  }

  emitDropzoneUploading() {
    this.file.status = Dropzone.UPLOADING;
    this.source.dropZone.emit("processing", this.file);
  }

  emitDropzoneError(error) {
    this.file.status = Dropzone.ERROR;
    this.source.dropZone.emit("error", this.file, error);
    this.source.dropZone.emit("complete", this.file);
  }

  emitDropzoneSuccess() {
    this.file.status = Dropzone.SUCCESS;
    this.source.dropZone.emit("success", this.file);
    this.source.dropZone.emit("complete", this.file);
  }
}

function createDirectUploadController(source, file) {
  return new DirectUploadController(source, file);
}

function createDirectUpload(file, url, controller) {
  return new DirectUpload(file, url, controller);
}

function createDropZone(controller) {
  return new Dropzone(controller.element, {
    url: controller.url,
    headers: controller.headers,
    maxFiles: controller.maxFiles,
    maxFilesize: controller.maxFileSize,
    acceptedFiles: controller.acceptedFiles,
    addRemoveLinks: controller.addRemoveLinks,
    autoQueue: false
  });
}

There is a lot going on here as you can see. Much of the logic deals with event listening and getting values from the front-end to pass to our dropzone instance. We hook into both dropzone and active storage to make the uploads work as advertised. I import those helpers I mentioned prior and reference them here.

This gets us close but our drag and drop zone doesn't look the part. I leverage Tailwind CSS already for the application styles. We can import the defaults from Dropzone directly thanks to modern Webpack and JavaScript. Doing so takes place in my pre-existing _forms.scss partial.

/* app/javascript/stylesheets/components/_form.scss */

@import "dropzone/dist/min/dropzone.min.css";
@import "dropzone/dist/min/basic.min.css";

.input {
  @apply appearance-none block w-full bg-gray-100 text-gray-700 border border-gray-300 rounded py-3 px-4 leading-tight;
}

.input:focus {
  @apply outline-none bg-white border-gray-400;
}

.label {
  @apply block text-gray-700 text-sm font-bold mb-2;
}

.select {
  @apply appearance-none py-3 px-4 pr-8 block w-full bg-gray-100 border border-gray-300 text-gray-700
   rounded leading-tight;
  -webkit-appearance: none;
}

.select:focus {
  @apply outline-none bg-white border-gray-400;
}

.dropzone {
  @apply border-2 rounded-lg border-gray-400 border-dashed;

  &.dz-drag-hover {
    @apply border-2 rounded-lg border-gray-200 border-dashed;

    .dz-message {
      opacity: 0.9;
    }
  }
}

Pay the most attention to the @import statements at the beginning of the file and the .dropzone class at the end. We extend it a bit with some Tailwind specific CSS to get the UI to look like the following:

Dropzone UI complete

It looks a lot more like a drag and drop uploader now huh? Sweet. If all goes correctly this should be in working order.

Looks like it worked for me! If you run into errors it could be due to image size/type of which we passed through on the form element itself with data-attributes. You can adjust those accordingly for your needs.

drag and drop functionality works

Displaying the upload

In a production app, you probably want to configure where your uploads via Active Storage get stored. You can do that quite easily in config/storage.yml. There are loads of options to choose from in terms of storage providers. You can pass your API keys through and be done.

For the purposes of this tutorial, we are leveraging local system storage. It works fine for development.

To see our work we still need to display the upload on the index and show views. I'll update those to reflect.

<!-- app/views/posts/index.html.erb-->

<div class="max-w-6xl m-auto">
  <div class="flex items-center justify-between pb-4 mb-4 border-b">
    <h1 class="text-xl font-bold mb-0 uppercase text-gray-500">Posts</h1>
    <%= link_to "New Post", new_post_path, class: "btn btn-default" if user_signed_in? %>
  </div>

<% @posts.each do |post| %>
  <article class="border rounded-lg lg:w-1/3 w-full">
    <%= link_to post do %>
      <%= image_tag post.feature_image if post.feature_image.present? %>
    <% end %>

    <div class="p-6">
      <h1 class="text-2xl font-bold"><%= link_to post.title, post %></h1>

      <div class="leading-normal text-lg">
        <%= post.body %>
      </div>

      <% if user_signed_in? && current_user.id == post.id %>
        <div class="my-4">
          <%= link_to 'Edit', edit_post_path(post), class: "btn btn-default" %>
          <%= link_to 'Delete', post, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
      </div>
      <% end %>
    <% end %>
  </div>
</div>

And finally the show view

<div class="max-w-4xl m-auto">
  <%= link_to @post do %>
    <%= image_tag @post.feature_image if @post.feature_image.present? %>
  <% end %>

  <div class="p-6">
    <h1 class="text-2xl font-bold"><%= link_to @post.title, @post %></h1>

    <div class="leading-normal text-lg">
      <%= @post.body %>
    </div>

    <% if user_signed_in? && current_user.id == @post.id %>
      <div class="my-4">
        <%= link_to 'Edit', edit_post_path(@post), class: "btn btn-default" %>
        <%= link_to 'Delete', @post, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
    </div>
    <% end %>
  </div>
</div>

I went ahead and updated the routing to default to /posts as the root path. This is done like so:

# config/routes.rb

require 'sidekiq/web'

Rails.application.routes.draw do
  resources :posts
    authenticate :user, lambda { |u| u.admin? } do
      mount Sidekiq::Web => '/sidekiq'
    end


  devise_for :users
  root to: 'posts#index' # change to `posts#index`
end

Closing

There you have it! While it was a little bit of work, we now have drag and drop uploads with a reusable Stimulus.js component. We leveraged Dropzone.js and Active Storage direct upload to trigger uploads in the browser with JavaScript all while leveraging the core aspects of Ruby on Rails we all know and love. I hope you learned something from this guide. Feel free to tweak the code to match your own needs.

Shameless plug time

Hello Rails Course

I have a new course called Hello Rails. Hello Rails is modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. View the course!

Follow @hello_rails and myself @justalever on Twitter.

Link this article
Est. reading time: 19 minutes
Stats: 32,960 views

Categories

Collection

Part of the Ruby on Rails collection

Products and courses