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.
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:
- Stimulus JS
- Dropzone JS
- Webpack/Rails 6.0
- Kickoff Tailwind (My personal starter template for Rails apps)
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
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.
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:
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.
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
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.
Categories
Collection
Part of the Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.