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.

Dropzone with direct upload

Prerequisites #

Before you begin, make sure you have the following set up:

  1. Install Active Storage: If you haven’t already, run rails active_storage:install and then migrate your database.
  2. Add Active Storage Dependency: Include Active Storage by running yarn add activestorage
  3. 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.