ActiveStorage Direct Upload with Stimulus

railsdesigner

Rails Designer

Posted on October 3, 2024

ActiveStorage Direct Upload with Stimulus

This article was originally published on Rails Designer


In two previous articles I explored first previewing images before upload and then a drag & drop feature. In this article I am going, once again, extend the functionality by adding a direct upload feature.

Direct Upload in ActiveStorage allows files to be uploaded directly from the user to the cloud storage service (eg. S3), without touching your app's server. This is mostly useful for larger files like audio and video, but nonetheless useful for images too.

Let's start, also this time, with the HTML where the previous article ended:

<div data-controller="image-preview dropzone" data-dropzone-image-preview-outlet="#image-preview" data-action="dragover->dropzone#dragOver dragleave->dropzone#dragLeave drop->dropzone#drop" id="image-preview>
  <img data-image-preview-target="canvas" hidden class="object-cover size-48">

  <input type="file" accept="image/*" data-image-preview-target="source" data-dropzone-target="input" data-action="image-preview#show" hidden>
</div>
Enter fullscreen mode Exit fullscreen mode

Let's add some attributes to it:

<div data-controller="image-preview dropzone direct-upload" data-dropzone-image-preview-outlet="#image-preview" data-action="dragover->dropzone#dragOver dragleave->dropzone#dragLeave drop->dropzone#drop" data-direct-upload-url-value="<%%= rails_direct_uploads_url %>" id="image-preview>
  <img data-image-preview-target="canvas" hidden class="object-cover size-48">

  <input type="file" accept="image/*" data-image-preview-target="source" data-dropzone-target="input" data-direct-upload-target="input" data-action="image-preview#show change->direct-upload#now" hidden>
  <span data-direct-upload-target="progressBar" class="block h-1 bg-gray-900 rounded-full" style="width: 0%"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

From top to bottom, this is what's added:

  • direct-upload to data-controller;
  • data-direct-upload-url-value="<%%= rails_direct_uploads_url %>";
  • added data-direct-upload-target="input" to the input field;
  • added change->direct-upload#now to the input'sdata-action;
  • and lastly added a span-element for the upload progress.

That's a fair bit! Let's take a look at the direct_upload_controller.js.

import { Controller } from "@hotwired/stimulus";
import { DirectUpload } from "@rails/activestorage";

export default class extends Controller {
  static targets = ["input", "progressBar"];
  static values = { url: String };

  now() {
    Array.from(this.inputTarget.files).forEach(file => this.#uploadFile(file));

    this.inputTargets.forEach(input => input.value = null);
  }
}
Enter fullscreen mode Exit fullscreen mode

Alright this needs DirectUpload from @rails/activestorage. So either install it using NPM or pin it using importmaps.

The now() function calls #uploadFile() for each file added to the input element.

// …

export default class extends Controller {
  // …

  // private
  #uploadFile(file) {
    new DirectUpload(
      file,
      this.urlValue,
      this // callback directUploadWillStoreFileWithXHR(request)
    ).create((error, blob) => {
      if (error) {
        console.log(error);
      } else {
        this.#createHiddenInput(blob);
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

It creates a new DirectUpload instance from @rails/activestorage. this references the current object, used as a callback for directUploadWillStoreFileWithXHR. Let's create the the functions used here.

// …

export default class extends Controller {
  // …

  // private

  // …

  #createHiddenInput(blob) {
    const hiddenField = document.createElement("input");

    hiddenField.setAttribute("id", `attachment_${blob.filename}`);
    hiddenField.setAttribute("type", "hidden");
    hiddenField.setAttribute("value", blob.signed_id);
    hiddenField.name = this.element.name;

    this.element.appendChild(hiddenField);
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      this.#progressUpdate(event);
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

The #createHiddenInput method generates a hidden form input containing the uploaded file's metadata, appending it to the DOM. The directUploadWillStoreFileWithXHR method attaches a progress event listener to the upload request, enabling real-time tracking of the upload process.

Now let's create the #progressUpdate function:

// …

export default class extends Controller {
  // …

  // private

  // …
  #progressUpdate(event) {
    const progress = (event.loaded / event.total) * 100;

    this.progressBarTargets.forEach((progressBar) => {
      progressBar.style.width = `${progress}%`;
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Wow! Now, when you select or drag&drop an image:

  • you will see a preview of it;
  • it is uploaded directly to your Cloud Storage Provider.

What's next?

There are a few more things to take care off.

Cross-Origin Resource Sharing (CORS)

For direct uploads to work, you need to set up CORS. This is needed to allow the browser to make direct upload requests. The official docs has some decent info around this topic.

Purge attachments

Another thing to keep in mind is that with directly uploaded files, they might never be attached to a record. A simple solution is to have a job run regularly. Something like this:

class PurgeUnattachedJob < ApplicationJob
  def perform
    ActiveStorage::Blob
      .unattached
      .where(created_at: ..1.day.ago)
      .find_each(&:purge_later)
  end
end
Enter fullscreen mode Exit fullscreen mode

And that concludes this trilogy of image uploads with Rails' ActiveStorage together with Stimulus.

💖 💪 🙅 🚩
railsdesigner
Rails Designer

Posted on October 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related