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>
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>
From top to bottom, this is what’s added:
direct-upload
todata-controller
;- added
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 a span-element for the upload progress.
That’s a fair amount of code added! Let’s now write 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);
}
}
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);
}
})
}
}
It creates a new DirectUpload
instance from @rails/activestorage
. this
references the current object, used as a callback for directUploadWillStoreFileWithXHR
. Let’s create 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);
})
}
}
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, to show the progress 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}%`;
})
}
}
Wow—that is all! 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
And that concludes this trilogy of image uploads with Rails’ ActiveStorage together with Stimulus.