Direct Upload with Rails & Dropzone.js
Configuring direct file uploads in Rails is now easier than ever. In this article, I’ll show you how to create a file upload component in Rails 7. We’ll use Active Storage, Stimulus, and Dropzone.js to make the process smooth and user-friendly.
Prerequisites
Before you begin, make sure you have the following set up:
- Install Active Storage: If you haven’t already, run
rails active_storage:install
and then migrate your database. - Add Active Storage Dependency: Include Active Storage by running
yarn add activestorage
- Configure CORS for Your Storage Service: If you use a cloud storage service like AWS S3, configure Cross-Origin Resource Sharing (CORS). This allows your frontend to communicate with the storage service securely. For details on setting up CORS for Active Storage, see the Rails documentation .
Step 1: Setting Up the Dropzone Controller
First, create a dropzone_controller.js
file. This Stimulus controller manages file uploads and integrates with Dropzone.js and Active Storage’s Direct Upload.
Key Features:
- Event Binding: The controller binds events like file addition, removal, and upload completion.
- Direct Upload Handling: When a file is added, it creates a
DirectUpload
instance to handle the upload process. - Custom Headers: CSRF tokens are included in the upload request headers for security.
Here’s how our dropzone_controller.js
looks:
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"
import Dropzone from "dropzone"
Dropzone.autoDiscover = false
export default class extends Controller {
static targets = ["input", "previewsContainer", "previewTemplate"]
connect() {
this.dropZone = createDropZone(this)
this.bindEvents()
}
bindEvents() {
this.dropZone.on("addedfile", (file) => {
setTimeout(() => { file.accepted && createDirectUploadController(this, file).start() }, 200)
})
this.dropZone.on("removedfile", (file) => {
file.controller && this.removeElement(file.controller.hiddenInput)
})
this.dropZone.on("canceled", (file) => {
file.controller && file.controller.xhr.abort()
})
}
get headers() {
const csrf = document.querySelector(`meta[name="csrf-token"]`).getAttribute("content")
return { "X-CSRF-Token": csrf }
}
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 previewsContainer() { return `#${this.previewsContainerTarget.id}` }
get previewTemplate() { return this.previewTemplateTarget.innerHTML }
removeElement(el) {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
}
removeExisting(event) {
this.removeElement(event.target.parentNode)
}
}
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) {
this.source.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
this.file.previewTemplate.append(input)
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 progress = event.loaded / event.total * 100
document.querySelector(".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: false,
autoQueue: false,
createImageThumbnails: false,
previewsContainer: controller.previewsContainer,
previewTemplate: controller.previewTemplate
})
}
Step 2: Creating the Dropzone HTML Template
Next, create a template _dropzone_files_input.html.erb
, which will be rendered in your form.
Key Features:
- Dynamic Attributes: Set max file size and file count dynamically.
- Previews Container: A container for file previews with customizable templates.
- File Input: The file input is hidden and managed through Stimulus and Dropzone.
Here’s the HTML template:
<% max_size ||= 10 %>
<% max_files ||= 10 %>
<% file_input_name ||= :file %>
<% data ||= {} %>
<div
class="my-dropzone"
data-controller="dropzone"
data-dropzone-max-file-size="<%= max_size %>"
data-dropzone-max-files="<%= max_files %>">
<%= form.file_field(
file_input_name,
direct_upload: true,
class: 'hidden',
multiple: true,
disabled: true,
data: data.merge({target: 'dropzone.input'})
) %>
<div class="dz-message needsclick drop-container">
<div class="message-container">
<div>
<a>Click to upload</a> or drag and drop your files here.
</div>
<div>Maximum file size <%= max_size %> MB.</div>
</div>
</div>
<div id="dz-previews-container" data-target="dropzone.previewsContainer" class="previews-container">
<% attachments.each do |attachment| %>
<div class="dz-preview dz-file-preview preview-file">
<%= form.hidden_field(file_input_name, value: attachment.signed_id, multiple: true) %>
<div class="dz-progress"><div class="dz-upload" data-dz-uploadprogress style="width: 100%;"></div></div>
<i class="fa-light fa-file file-icon"></i>
<div class="dz-details file-details">
<div class="dz-filename"><span><%= attachment.filename %></span></div>
<div class="dz-size"><%= localize_filesize(attachment.byte_size) %></div>
</div>
<i class="fa-light fa-times file-remove-icon" data-action="click->dropzone#removeExisting"></i>
</div>
<% end %>
</div>
<div class="hidden">
<div data-target="dropzone.previewTemplate">
<div class="dz-preview dz-file-preview preview-file">
<div class="dz-progress"><div class="dz-upload" data-dz-uploadprogress style="width: 0;"></div></div>
<i class="fa-light fa-file file-icon"></i>
<div class="dz-details file-details">
<div class="dz-filename"><span data-dz-name></span></div>
<div class="dz-size" data-dz-size></div>
</div>
<div class="dz-error-message"><span data-dz-errormessage></span></div>
<i class="fa-light fa-times file-remove-icon" data-dz-remove></i>
</div>
</div>
</div>
</div>
Step 3: Adding Styles
To give the dropzone a clean and modern look, add some CSS styles:
.my-dropzone {
.drop-container {
border: 1px #999 solid;
border-radius: 8px;
padding: 20px;
cursor: pointer;
}
.message-container {
margin: 2em 0;
text-align: center;
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
color: #999;
}
.previews-container {
margin-top: 30px;
}
.preview-file {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px #999 solid;
padding: 15px 30px;
background-color: #ebebeb;
&:not(:first-child) {
margin-top: 10px;
}
.file-icon {
font-size: 24px;
z-index: 2;
}
.file-details {
flex-grow: 1;
margin-left: 20px;
z-index: 2;
.dz-filename {
font-weight: 600;
}
.dz-size {
color: #7c7c7c;
strong {
font-weight: 100;
}
}
}
.file-remove-icon {
color: black;
font-size: 24px;
cursor: pointer;
z-index: 2;
}
.dz-progress {
z-index: 1;
position: absolute;
width: 100%;
height: 100%;
.dz-upload {
background-color: #fff;
height: 100%;
}
}
.dz-error-message {
color: red;
}
}
}
Step 4: Using the Component in a Form
Finally, render the component inside your form like this:
<%= render(
'dropzone_files_upload',
form: form,
file_input_name: :files,
max_size: 5,
max_files: 10,
attachments: @post.files
) %>
This rendering adds file upload functionality to your form. You can customize it further by adjusting parameters for file size, file count, and more.
Possible Improvements
Add Image Thumbnails
To show image thumbnails, set createImageThumbnails
to true
when creating a Dropzone
instance. Then, modify the preview template to render the thumbnail image:
<div class="dz-preview dz-file-preview">
...
<img data-dz-thumbnail />
<div class="dz-details file-details">
<div class="dz-filename"><span data-dz-name></span></div>
<div class="dz-size" data-dz-size></div>
</div>
...
</div>
Invoke change
Events
If you have a form_controller.js
with custom validation logic, and you need to validate each time a file is added or removed, you can use this approach:
<%= render(
'dropzone_files_upload',
form: form,
file_input_name: :files,
data: {action: 'change->form#validate'}
) %>
To support this, define a helper function that triggers the change event:
invokeChangeEvent() {
this.inputTarget.dispatchEvent(new Event('change'))
}
Invoke this function when adding or removing files:
bindEvents() {
...
this.dropZone.on("complete", (file) => {
this.invokeChangeEvent()
})
}
removeElement(el) {
if (el && el.parentNode) {
el.parentNode.removeChild(el)
this.invokeChangeEvent()
}
}
Conclusion
By integrating Rails 7 with Active Storage, Stimulus, and Dropzone.js, you can create a powerful and flexible file upload component. This setup provides users with features like drag-and-drop, progress bars, and easy file management.